From 715569a8eb88c7524748af8bd0d8a4366870ecd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 28 Sep 2022 18:29:06 +0200 Subject: [PATCH 01/10] support new gitlab version with can "delete" time notes --- src/models/hasTimes.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/models/hasTimes.js b/src/models/hasTimes.js index 8d0b300..c6ffaba 100755 --- a/src/models/hasTimes.js +++ b/src/models/hasTimes.js @@ -6,6 +6,7 @@ const Time = require('./time'); const regex = /added (.*) of time spent(?: at (.*))?/i; const subRegex = /subtracted (.*) of time spent(?: at (.*))?/i; +const delRegex = /deleted (.*) of spent time(?: from (.*))?/i; const removeRegex = /Removed time spent/i; /** @@ -72,23 +73,43 @@ class hasTimes extends Base { }); let promise = this.parallel(this.notes, (note, done) => { - let created = moment(note.created_at), match, subMatch; + let created = moment(note.created_at), match, subMatch, delMatch; if ( // // filter out user notes !note.system || // filter out notes that are no time things - !(match = regex.exec(note.body)) && !(subMatch = subRegex.exec(note.body)) && !removeRegex.exec(note.body) + !(match = regex.exec(note.body)) && !(subMatch = subRegex.exec(note.body)) && !removeRegex.exec(note.body) && !(delMatch = delRegex.exec(note.body)) ) return done(); // change created date when explicitly defined if(match && match[2]) created = moment(match[2]); if(subMatch && subMatch[2]) created = moment(subMatch[2]); + if(delMatch && delMatch[2]) created = moment(delMatch[2]); // create a time string and a time object - let timeString = match ? match[1] : (subMatch ? `-${subMatch[1]}` : `-${Time.toHumanReadable(timeSpent)}`); + let timeString = null; + let multiplier = 1; + if(match) { + timeString = match[1]; + } + else if(subMatch) { + timeString = subMatch[1]; + multiplier = -1; + } + else if(delMatch){ + timeString = delMatch[1]; + multiplier = -1;; + } + else { + // Removed time spent -> remove all + timeString = Time.toHumanReadable(timeSpent); + multiplier = -1; + } + let time = new Time(null, created, note, this, this.config); - time.seconds = Time.parse(timeString, 8, 5, 4); + time.seconds = Time.parse(timeString, 8, 5, 4) * multiplier; + // add to total time spent totalTimeSpent += time.seconds; From 307d85ba7bddb2d350187dd1db9c69117315601c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 28 Sep 2022 18:30:10 +0200 Subject: [PATCH 02/10] extra position on invoice --- src/gtt-report.js | 4 ++++ src/output/invoice.js | 21 +++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/gtt-report.js b/src/gtt-report.js index 2a16905..9738be9 100755 --- a/src/gtt-report.js +++ b/src/gtt-report.js @@ -74,6 +74,8 @@ program .option('--invoiceDate ', 'date string') .option('--invoiceCurrencyMaxUnit ', 'rouning invoice total, e.g. 0.01, 0.05 or 1') .option('--invoicePositionText ', 'invoice position text') + .option('--invoicePositionExtraText ', 'extra invoice position: text') + .option('--invoicePositionExtraValue ', 'extra invoice position: value') .parse(process.argv); // init helpers @@ -142,6 +144,8 @@ config .set('invoiceDate', program.opts().invoiceDate) .set('invoiceCurrencyMaxUnit', program.opts().invoiceCurrencyMaxUnit) .set('invoicePositionText', program.opts().invoicePositionText) + .set('invoicePositionExtraText', program.opts().invoicePositionExtraText) + .set('invoicePositionExtraValue', program.opts().invoicePositionExtraValue) .set('_createDump', program.opts().output === 'dump'); // date shortcuts diff --git a/src/output/invoice.js b/src/output/invoice.js index 0b07ee3..2a057e5 100644 --- a/src/output/invoice.js +++ b/src/output/invoice.js @@ -21,11 +21,18 @@ class invoice extends Base { this.invoiceCurrencyPerHour = this.config.get('invoiceCurrencyPerHour'); this.invoiceVAT = this.config.get('invoiceVAT'); this.invoicePositionText = this.config.get('invoicePositionText'); + this.invoicePositionExtraText = this.config.get('invoicePositionExtraText'); + this.invoicePositionExtraValue = parseFloat(this.config.get('invoicePositionExtraValue')); + if(!this.invoicePositionExtraValue > 0) { + this.invoicePositionExtraValue = 0.0; + } this.invoiceCurrencyMaxUnit = this.config.get('invoiceCurrencyMaxUnit'); - this.totalhForInvoice = (this.spent-this.spentFree) / 3600.0; + this.totalhForInvoice = (this.spent-this.spentFree-(this.spentHalfPrice*0.5)) / 3600.0; // 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; + let invoiceTotal = this.totalhForInvoice * this.invoiceCurrencyPerHour + this.invoicePositionExtraValue; + this.totalForInvoiceH = Math.round(this.totalhForInvoice * this.invoiceCurrencyPerHour * 100) * 0.01; + this.totalForInvoiceExkl = Math.round(invoiceTotal * 100) * 0.01; + this.totalForInvoiceMwst = Math.round(invoiceTotal * this.invoiceVAT * 100) * 0.01; this.totalForInvoice = Math.round((this.totalForInvoiceExkl + this.totalForInvoiceMwst)/this.invoiceCurrencyMaxUnit)*this.invoiceCurrencyMaxUnit; } @@ -124,6 +131,11 @@ class invoice extends Base { svg.instance.viewBox(0,0,740,420) svg.instance.height(""); svg.instance.width(""); + let extra = ""; + if(this.invoicePositionExtraValue > 0) { + extra = `
${this.invoicePositionExtraText}
+
${this.invoiceCurrency} ${this.invoicePositionExtraValue.toFixed(2)}
`; + } this.out += `
${from}
@@ -140,7 +152,8 @@ ${opening}
${this.invoicePositionText} (${this.totalhForInvoice.toFixed(2)} Stunden zu ${this.invoiceCurrencyPerHour} ${this.invoiceCurrency})
-
${this.invoiceCurrency} ${this.totalForInvoiceExkl.toFixed(2)}
+
${this.invoiceCurrency} ${this.totalForInvoiceH.toFixed(2)}
+${extra}
MWST (${this.invoiceVAT*100}%)
${this.invoiceCurrency} ${this.totalForInvoiceMwst.toFixed(2)}
Rechnungsbetrag inkl. MWST
From cf1ec45ea8c0a09d9688fab8144edab650025c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 28 Sep 2022 18:30:31 +0200 Subject: [PATCH 03/10] support half-price labels --- src/output/base.js | 16 ++++++++++++++++ src/output/invoice.js | 1 + 2 files changed, 17 insertions(+) diff --git a/src/output/base.js b/src/output/base.js index b815d67..1d9f29b 100755 --- a/src/output/base.js +++ b/src/output/base.js @@ -109,6 +109,7 @@ class base { let totalSpent = 0; let spent = 0; let spentFree = 0; + let spentHalfPrice = 0; let users = {}; let projects = {}; let times = []; @@ -120,6 +121,10 @@ class base { if(undefined === spentFreeLabels) { spentFreeLabels = []; } + let spentHalfPriceLabels = this.config.get('halfPriceLabels'); + if(undefined === spentHalfPriceLabels) { + spentHalfPriceLabels = []; + } ['issues', 'mergeRequests'].forEach(type => { this.report[type].forEach(issue => { @@ -146,14 +151,24 @@ class base { spent += time.seconds; //if(time.parent.labels) let free = false; + let halfPrice = false; time.parent.labels.forEach(label => { spentFreeLabels.forEach(freeLabel => { free |= (freeLabel == label); }); }); + time.parent.labels.forEach(label => { + spentHalfPriceLabels.forEach(halfPriceLabel => { + halfPrice |= (halfPriceLabel == label); + }); + }); + if(free) { spentFree += time.seconds; } + if(halfPrice) { + spentHalfPrice += time.seconds; + } times.push(time); }); issue.timesWarnings.forEach(warning => timesWarnings.push(warning)); @@ -190,6 +205,7 @@ class base { this.totalEstimate = totalEstimate; this.spent = spent; this.spentFree = spentFree; + this.spentHalfPrice = spentHalfPrice; this.totalSpent = totalSpent; this.timesWarnings = timesWarnings; } diff --git a/src/output/invoice.js b/src/output/invoice.js index 2a057e5..db9ae0d 100644 --- a/src/output/invoice.js +++ b/src/output/invoice.js @@ -173,6 +173,7 @@ ${closing} this.headline('Total'); //this.write(stats.substr(1)); this.write(this.config.toHumanReadable(this.spent, 'stats')); + this.write(this.config.toHumanReadable(this.spentHalfPrice, 'statsHalfPrice')); this.write(this.config.toHumanReadable(this.spentFree, 'statsFree')); // warnings From 704ae12133de801f28b7f4f41977dcc06687748b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 28 Sep 2022 18:30:47 +0200 Subject: [PATCH 04/10] removed project from detail table in invoice --- src/output/invoice.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/output/invoice.js b/src/output/invoice.js index db9ae0d..a0e0fd8 100644 --- a/src/output/invoice.js +++ b/src/output/invoice.js @@ -221,7 +221,7 @@ ${closing}



Stundenrapport detailliert

`; this.headline('Details'); - let times = [['date', 'project', 'iid', 'time']]; + let times = [['date', 'iid', 'time']]; let days = Object.keys(this.days); days.sort(); days.forEach( @@ -235,7 +235,7 @@ ${closing} iids.sort(); iids.forEach( iid => { - times.push([refD, p, iid, this.config.toHumanReadable(day[p][iid], 'records')]); + times.push([refD, iid, this.config.toHumanReadable(day[p][iid], 'records')]); }); }); }); From 7434c759c1ba34c777accb0cc346cfabec71d9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sat, 19 Nov 2022 16:15:20 +0100 Subject: [PATCH 05/10] fixed html --- src/output/invoice.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/output/invoice.js b/src/output/invoice.js index a0e0fd8..cf725c5 100644 --- a/src/output/invoice.js +++ b/src/output/invoice.js @@ -62,10 +62,10 @@ class invoice extends Base { } // REMOVE // _.each(this.users, (time, name) => stats += `\n* **${name}**: ${time}`); - let to = this.concat(this.config.get('invoiceAddress'), '
'); - 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 to = this.concat(this.config.get('invoiceAddress'), '
'); + 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, '
'); // QR bill let endOfZipPos = this.config.get('invoiceSettings').from[3].search("[ _]"); From 694fafaa227d0a41193a5b00fa9d95685774324e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 22 Nov 2022 18:48:02 +0100 Subject: [PATCH 06/10] gtt log: show unsynced / incompletely synced times yellow --- src/gtt-log.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/gtt-log.js b/src/gtt-log.js index a7e973b..0fbfb4d 100755 --- a/src/gtt-log.js +++ b/src/gtt-log.js @@ -46,8 +46,10 @@ tasks.log() frames[date] .sort((a, b) => a.start.isBefore(b.start) ? -1 : 1) .forEach(frame => { + let toSync = (Math.ceil(frame.duration) - parseInt(_.reduce(frame.notes, (n, m) => (n + m.time), 0))) != 0; + let durationText = toSync ? toHumanReadable(frame.duration).yellow : toHumanReadable(frame.duration); let issue = frame.resource.new ? `new ${frame.resource.type + ' "' + frame.resource.id.blue}"` : `${(frame.resource.type + ' #' + frame.resource.id).blue}`; - console.log(` ${frame.id} ${frame.start.clone().format('HH:mm').green} to ${frame.stop.clone().format('HH:mm').green}\t${toHumanReadable(frame.duration)}\t\t${frame.project.magenta}\t\t${issue}`) + console.log(` ${frame.id} ${frame.start.clone().format('HH:mm').green} to ${frame.stop.clone().format('HH:mm').green}\t${durationText}\t\t${frame.project.magenta}\t\t${issue}`) }); }); } From 800b0160b004539e75f28e881f58794209b0ebed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 25 Nov 2022 18:22:35 +0100 Subject: [PATCH 07/10] add a note to timeentries --- src/gtt-log.js | 2 +- src/gtt-start.js | 7 ++++++- src/gtt-status.js | 2 +- src/include/tasks.js | 6 +++--- src/models/baseFrame.js | 13 ++++++++++--- src/models/frame.js | 3 ++- src/models/hasTimes.js | 10 ++++++++-- 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/gtt-log.js b/src/gtt-log.js index 0fbfb4d..835afc3 100755 --- a/src/gtt-log.js +++ b/src/gtt-log.js @@ -49,7 +49,7 @@ tasks.log() let toSync = (Math.ceil(frame.duration) - parseInt(_.reduce(frame.notes, (n, m) => (n + m.time), 0))) != 0; let durationText = toSync ? toHumanReadable(frame.duration).yellow : toHumanReadable(frame.duration); let issue = frame.resource.new ? `new ${frame.resource.type + ' "' + frame.resource.id.blue}"` : `${(frame.resource.type + ' #' + frame.resource.id).blue}`; - console.log(` ${frame.id} ${frame.start.clone().format('HH:mm').green} to ${frame.stop.clone().format('HH:mm').green}\t${durationText}\t\t${frame.project.magenta}\t\t${issue}`) + console.log(` ${frame.id} ${frame.start.clone().format('HH:mm').green} to ${frame.stop.clone().format('HH:mm').green}\t${durationText}\t\t${frame.project.magenta}\t\t${issue}\t\t${frame.note!=null?frame.note:''}`) }); }); } diff --git a/src/gtt-start.js b/src/gtt-start.js index 65fe387..abaef26 100755 --- a/src/gtt-start.js +++ b/src/gtt-start.js @@ -12,6 +12,7 @@ program .option('-m', 'shorthand for --type=merge_request') .option('-i', 'shorthand for --type=issue') .option('--verbose', 'show verbose output') + .option('--note ', 'specify note') .parse(process.argv); Cli.verbose = program.opts().verbose; @@ -27,6 +28,10 @@ if (program.opts().i) { } else if (program.opts().m) { type = 'merge_request'; } +let note = null; +if (program.opts().note) { + note = program.opts().note; +} if (program.args.length < 2 && !config.get('project')) Cli.error('No project set'); @@ -34,6 +39,6 @@ if (program.args.length < 2 && !config.get('project')) if (!id) Cli.error('Wrong or missing issue/merge_request id'); -tasks.start(project, type, id) +tasks.start(project, type, id, note) .then(frame => console.log(`Starting project ${config.get('project').magenta} ${type.blue} ${('#' + id).blue} at ${moment().format('HH:mm').green}`)) .catch(error => Cli.error(error)); \ No newline at end of file diff --git a/src/gtt-status.js b/src/gtt-status.js index a0fde90..6b1362c 100755 --- a/src/gtt-status.js +++ b/src/gtt-status.js @@ -22,6 +22,6 @@ tasks.status() return; } - frames.forEach(frame => console.log(`Project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} is running, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); + frames.forEach(frame => console.log(`Project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} "${frame.note}" is running, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); }) .catch(error => Cli.error('Could not read frames.', error)); \ No newline at end of file diff --git a/src/include/tasks.js b/src/include/tasks.js index a160509..8f3f199 100755 --- a/src/include/tasks.js +++ b/src/include/tasks.js @@ -117,7 +117,7 @@ class tasks { return new Promise((resolve, reject) => { let resource = this.sync.resources[frame.resource.type][frame.resource.id]; - resource.createTime(Math.ceil(time), frame._stop) + resource.createTime(Math.ceil(time), frame._stop, frame.note) .then(() => resource.getNotes()) .then(() => { if (frame.resource.new) { @@ -213,7 +213,7 @@ class tasks { * @param id * @returns {Promise} */ - start(project, type, id) { + start(project, type, id, note) { this.config.set('project', project); return new Promise((resolve, reject) => { @@ -222,7 +222,7 @@ class tasks { if (frames.length > 0) return reject("Already running. Please stop it first with 'gtt stop'."); - resolve(new Frame(this.config, id, type).startMe()); + resolve(new Frame(this.config, id, type, note).startMe()); }) .catch(error => reject(error)); }) diff --git a/src/models/baseFrame.js b/src/models/baseFrame.js index ef4e940..5d099c9 100755 --- a/src/models/baseFrame.js +++ b/src/models/baseFrame.js @@ -10,8 +10,9 @@ class baseFrame { * @param config * @param id * @param type + * @param note */ - constructor(config, id, type) { + constructor(config, id, type, note) { this.config = config; this.project = config.get('project'); this.resource = {id, type}; @@ -24,10 +25,11 @@ class baseFrame { this._stop = false; this.timezone = config.get('timezone'); this.notes = []; + this._note = note; } static fromJson(config, json) { - let frame = new this(config, json.resource.id, json.resource.type); + let frame = new this(config, json.resource.id, json.resource.type, json.note); frame.project = json.project; frame.id = json.id; frame._start = json.start; @@ -68,7 +70,8 @@ class baseFrame { start: frame._start, stop: frame._stop, timezone: frame.timezone, - modified: frame.modified + modified: frame.modified, + note: frame._note, }); } @@ -88,6 +91,10 @@ class baseFrame { return this.timezone ? this._stop ? moment(this._stop).tz(this.timezone) : false : (this._stop ? moment(this._stop) : false ); } + get note() { + return this._note; + } + /** * generate a unique id * @returns {number} diff --git a/src/models/frame.js b/src/models/frame.js index 6eb1e84..377e806 100755 --- a/src/models/frame.js +++ b/src/models/frame.js @@ -42,7 +42,8 @@ class frame extends BaseFrame { start: this._start, stop: this._stop, timezone: this.timezone, - modified: skipModified ? this.modified : moment() + modified: skipModified ? this.modified : moment(), + note: this._note }, null, "\t")); } diff --git a/src/models/hasTimes.js b/src/models/hasTimes.js index c6ffaba..5d5e53a 100755 --- a/src/models/hasTimes.js +++ b/src/models/hasTimes.js @@ -24,11 +24,17 @@ class hasTimes extends Base { * @param time * @returns {*} */ - createTime(time, created_at) { + createTime(time, created_at, note) { + if(note === null) { + note = ''; + } + else { + note = '\n\n' + note; + } var date = new Date(created_at); var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); return this.post(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`, { - body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt), + body: '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]'+' '+spentAt + note), }); } From 711606778b8bab5f2ff38e525876277f5e10b377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 29 Nov 2022 14:39:22 +0100 Subject: [PATCH 08/10] hacked report/invoice based on graphql which allows to get the notes to spent entries --- src/gtt-report.js | 20 ++- src/models/base.js | 33 +++++ src/models/dayReport.js | 51 +++++++ src/models/hasTimes.js | 46 +++++++ src/models/mergeRequest.js | 1 + src/models/report.js | 120 +++++++++++++++++ src/output/base.js | 37 +++-- src/output/invoice2.js | 270 +++++++++++++++++++++++++++++++++++++ 8 files changed, 564 insertions(+), 14 deletions(-) create mode 100644 src/models/dayReport.js create mode 100644 src/output/invoice2.js diff --git a/src/gtt-report.js b/src/gtt-report.js index 9738be9..befce9e 100755 --- a/src/gtt-report.js +++ b/src/gtt-report.js @@ -15,6 +15,7 @@ const Output = { pdf: require('./output/pdf'), markdown: require('./output/markdown'), invoice: require('./output/invoice'), + invoice2: require('./output/invoice2'), dump: require('./output/dump'), xlsx: require('./output/xlsx') }; @@ -72,7 +73,8 @@ program .option('--invoiceCurrencyPerHour ', 'hourly wage rate on invoice') .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('--invoiceTimeMaxUnit ', 'rounds up invoice times, e.g. 60 rounds every issue per day to 1 minute') + .option('--invoiceCurrencyMaxUnit ', 'rounding invoice total, e.g. 0.01, 0.05 or 1') .option('--invoicePositionText ', 'invoice position text') .option('--invoicePositionExtraText ', 'extra invoice position: text') .option('--invoicePositionExtraValue ', 'extra invoice position: value') @@ -142,6 +144,7 @@ config .set('invoiceCurrencyPerHour', program.opts().invoiceCurrencyPerHour) .set('invoiceVAT', program.opts().invoiceVAT) .set('invoiceDate', program.opts().invoiceDate) + .set('invoiceTimeMaxUnit', program.opts().invoiceTimeMaxUnit) .set('invoiceCurrencyMaxUnit', program.opts().invoiceCurrencyMaxUnit) .set('invoicePositionText', program.opts().invoicePositionText) .set('invoicePositionExtraText', program.opts().invoicePositionExtraText) @@ -312,6 +315,21 @@ new Promise(resolve => { .then(() => resolve()); })) + // get timelogs + .then(() => new Promise(resolve => { + Cli.list(`${Cli.fetch} Loading timelogs`); + + reports + .forEach((report, done) => { + report.getTimelogs() + .catch(error => done(error)) + .then(() => done()); + }) + .catch(error => Cli.x(`could not load timelogs.`, error)) + .then(() => Cli.mark()) + .then(() => resolve()); + })) + // merge reports .then(() => new Promise(resolve => { Cli.list(`${Cli.merge} Merging reports`); diff --git a/src/models/base.js b/src/models/base.js index 754aac5..621f64a 100755 --- a/src/models/base.js +++ b/src/models/base.js @@ -53,6 +53,39 @@ class base { })); } + + /** + * query the given path + * @param path + * @param data + * @returns {*} + */ + graphQL(data) { + // remove v4/ from url, add graphql + const path = this.url.substr(0, this.url.length-3) + 'graphql'; + + let key = base.createDumpKey(path, data); + if (this.config.dump) return this.getDump(key); + + return new Promise((resolve, reject) => throttle(() => { + request.post(`${path}`, { + json: true, + body: data, + insecure: this._insecure, + proxy: this._proxy, + resolveWithFullResponse: true, + headers: { + 'Authorization': 'Bearer '+this.token, + 'Content-Type': 'application/json' + } + }).then(response => { + if (this.config.get('_createDump')) this.setDump(response, key); + resolve(response); + }).catch(e => reject(e)); + })); + } + + /** * query the given path * @param path diff --git a/src/models/dayReport.js b/src/models/dayReport.js new file mode 100644 index 0000000..7cdc150 --- /dev/null +++ b/src/models/dayReport.js @@ -0,0 +1,51 @@ + +/** + * day model of one item + */ +class dayReport { + constructor(iid, title, spentAt, chargeRatio) { + this.iid = iid; + this.title = title; + this.spentAt = spentAt; + this.chargeRatio = chargeRatio; + + this.spent = 0; + this.notes = []; + } + + getIid() { + return this.iid; + } + + getTitle() { + return this.title; + } + getSpent(invoiceTimeMaxUnit) { + if(!invoiceTimeMaxUnit) { + invoiceTimeMaxUnit = 1.0; + } + return Math.ceil(this.spent / invoiceTimeMaxUnit) * invoiceTimeMaxUnit; + } + + getDate() { + return this.spentAt; + } + + getNotes() { + return this.notes; + } + + addSpent(seconds) { + this.spent += seconds; + } + addNote(note) { + this.notes.push(note); + } + getChargeRatio() { + return this.chargeRatio; + } + + + } + +module.exports = dayReport; diff --git a/src/models/hasTimes.js b/src/models/hasTimes.js index 5d5e53a..0eb1d86 100755 --- a/src/models/hasTimes.js +++ b/src/models/hasTimes.js @@ -3,6 +3,7 @@ const moment = require('moment'); const Base = require('./base'); const Time = require('./time'); +const DayReport = require('./dayReport'); const regex = /added (.*) of time spent(?: at (.*))?/i; const subRegex = /subtracted (.*) of time spent(?: at (.*))?/i; @@ -17,6 +18,7 @@ class hasTimes extends Base { super(config); this.times = []; this.timesWarnings = []; + this.days = {}; } /** @@ -60,6 +62,50 @@ class hasTimes extends Base { return promise; } + recordTimelogs(timelogs){ + + let spentFreeLabels = this.config.get('freeLabels'); + if(undefined === spentFreeLabels) { + spentFreeLabels = []; + } + let spentHalfPriceLabels = this.config.get('halfPriceLabels'); + if(undefined === spentHalfPriceLabels) { + spentHalfPriceLabels = []; + } + + let free = false; + let halfPrice = false; + this.labels.forEach(label => { + spentFreeLabels.forEach(freeLabel => { + free |= (freeLabel == label); + }); + }); + this.labels.forEach(label => { + spentHalfPriceLabels.forEach(halfPriceLabel => { + halfPrice |= (halfPriceLabel == label); + }); + }); + + + let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); + + + + timelogs.forEach( + (timelog) => { + let spentAt = moment(timelog.spentAt); + let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); + if(!this.days[dateGrp]) + { + this.days[dateGrp] = new DayReport(this.iid, this.title, spentAt, chargeRatio); + } + if(timelog.note && timelog.note.body) { + this.days[dateGrp].addNote(timelog.note.body); + } + this.days[dateGrp].addSpent(timelog.timeSpent); + }); + } + /** * set times (call set notes first) * @returns {Promise} diff --git a/src/models/mergeRequest.js b/src/models/mergeRequest.js index 5ea2f95..a0ec473 100755 --- a/src/models/mergeRequest.js +++ b/src/models/mergeRequest.js @@ -1,3 +1,4 @@ +const _ = require('underscore'); const hasTimes = require('./hasTimes'); /** diff --git a/src/models/report.js b/src/models/report.js index fd341e7..b2fade5 100755 --- a/src/models/report.js +++ b/src/models/report.js @@ -23,6 +23,8 @@ class report extends Base { this.issues = []; this.mergeRequests = []; + + this.timelogs = null; } /** @@ -110,6 +112,117 @@ class report extends Base { return issues.filter(issue => this.config.get('showWithoutTimes') || (issue.times && issue.times.length > 0)); } + + + + /** + * starts loading the timelogs data page after the cursor into the array timelogs and recurse to the next page as soon as results are received + * sets this.timelogs when the last page is received. + * @param {String} cursor + * @param {Array} timelogs + */ + getTimelogPage(cursor, timelogs) { + if(!timelogs) { + timelogs = []; + } + + const query = ` + query ($project: ID!, $after: String, $entryPerPage: Int, + $startTime:Time, $endTime:Time){ + project(fullPath: $project) { + name + timelogs(startTime: $startTime, endTime: $endTime, + first:$entryPerPage, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + user { + username + } + spentAt + timeSpent + summary + note { + body + url + } + mergeRequests:mergeRequest { + iid + projectId + } + issues:issue { + iid + projectId + } + } + } + } + } + ` + + let request = { + "query": query, + "variables": { + "project": this.project.data.path_with_namespace, + "after": (cursor===undefined)?'':cursor, + "entryPerPage": 30, + "startTime": this.config.get('from'), + "endTime": this.config.get('to') + } + }; + + let promise = this.graphQL(request); + promise.then(response => { + if (response.body.errors) { + this.timelogs = []; + } else { + if (response.body.data.project.timelogs.nodes) { + // add timelogs + timelogs.push(response.body.data.project.timelogs.nodes); + if (response.body.data.project.timelogs.pageInfo.hasNextPage) { + // get next page + this.getTimelogPage(response.body.data.project.timelogs.pageInfo.endCursor, timelogs); + } + else { + // all pages loaded. combine chunks into single array. + let timelogsAggr = []; + timelogs.forEach((timelogchunk) => { + timelogchunk.forEach((timelog) => { + timelogsAggr.push(timelog); + }); + }); + this.timelogs = timelogsAggr; + } + } + else { + this.timelogs = []; + } + } + } + ); + } + + + waitForTimelogs(resolve) { + if (this.timelogs == null) { + setTimeout(this.waitForTimelogs.bind(this), 50, resolve); + } else { + resolve(); + } + + } + + + getTimelogs() { + this.getTimelogPage(); + let prm = new Promise((resolve, reject) => { + this.waitForTimelogs(resolve); + }); + return prm; + } + /** * process the given input * @param input @@ -125,6 +238,11 @@ class report extends Base { let item = new model(this.config, data); item.project_namespace = this.projects[item.project_id]; + item.recordTimelogs(this.timelogs.filter( + timelog => timelog[input] && + timelog[input].iid == data.iid && + timelog[input].projectId == data.project_id)); + item.getNotes() .then(() => item.getTimes()) .catch(error => done(error)) @@ -159,6 +277,8 @@ class report extends Base { if (!this.members) this.members = []; this.members = this.members.concat(report.members ? report.members : []); this.projects = Object.assign(this.projects, report.projects); + if (!this.timelogs) this.timelogs = []; + this.timelogs = this.timelogs.concat(report.timelogs); } /** diff --git a/src/output/base.js b/src/output/base.js index 1d9f29b..219877c 100755 --- a/src/output/base.js +++ b/src/output/base.js @@ -116,6 +116,7 @@ class base { let timesWarnings = []; let days = {}; let daysMoment = {}; + let daysNew = {}; let spentFreeLabels = this.config.get('freeLabels'); if(undefined === spentFreeLabels) { @@ -128,6 +129,28 @@ class base { ['issues', 'mergeRequests'].forEach(type => { this.report[type].forEach(issue => { + + let free = false; + let halfPrice = false; + issue.labels.forEach(label => { + spentFreeLabels.forEach(freeLabel => { + free |= (freeLabel == label); + }); + }); + issue.labels.forEach(label => { + spentHalfPriceLabels.forEach(halfPriceLabel => { + halfPrice |= (halfPriceLabel == label); + }); + }); + + // consolidate all issues back in one day + Object.keys(issue.days).forEach((key) => { + if(!daysNew[key]) { + daysNew[key] = []; + } + daysNew[key].push(issue.days[key]); + }); + issue.times.forEach(time => { let dateGrp = time.date.format(this.config.get('dateFormatGroupReport')); if (!users[time.user]) users[time.user] = 0; @@ -149,19 +172,6 @@ class base { days[dateGrp][time.project_namespace][time.iid] += time.seconds; spent += time.seconds; - //if(time.parent.labels) - let free = false; - let halfPrice = false; - time.parent.labels.forEach(label => { - spentFreeLabels.forEach(freeLabel => { - free |= (freeLabel == label); - }); - }); - time.parent.labels.forEach(label => { - spentHalfPriceLabels.forEach(halfPriceLabel => { - halfPrice |= (halfPriceLabel == label); - }); - }); if(free) { spentFree += time.seconds; @@ -208,6 +218,7 @@ class base { this.spentHalfPrice = spentHalfPrice; this.totalSpent = totalSpent; this.timesWarnings = timesWarnings; + this.daysNew = daysNew; } /** diff --git a/src/output/invoice2.js b/src/output/invoice2.js new file mode 100644 index 0000000..615a579 --- /dev/null +++ b/src/output/invoice2.js @@ -0,0 +1,270 @@ +const _ = require('underscore'); + +const Table = require('markdown-table'); +const Base = require('./base'); + +const SwissQRBill = require("swissqrbill"); + +const format = { + headline: h => `\n### ${h}\n`, + warning: w => `${w}` +}; + +/** + * invoice, code heavily based on markdown.js + */ +class invoice2 extends Base { + constructor(config, report) { + super(config, report); + this.format = format; + 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.invoicePositionExtraText = this.config.get('invoicePositionExtraText'); + this.invoicePositionExtraValue = parseFloat(this.config.get('invoicePositionExtraValue')); + if(!this.invoicePositionExtraValue > 0) { + this.invoicePositionExtraValue = 0.0; + } + this.invoiceCurrencyMaxUnit = this.config.get('invoiceCurrencyMaxUnit'); + this.invoiceTimeMaxUnit = this.config.get('invoiceTimeMaxUnit'); + +/* + +
${this.invoicePositionText} (${this.totalhForInvoice.toFixed(2)} Stunden zu ${this.invoiceCurrencyPerHour} ${this.invoiceCurrency})
+
${this.invoiceCurrency} ${this.totalForInvoiceH.toFixed(2)}
+*/ + this.invoicePositions = {}; //key=iid, value=[text, total H, Rate, Total] + + Object.keys(this.daysNew).forEach( + k => { + let day = this.daysNew[k]; + day.forEach( + (dayReport) => { + let iid = dayReport.getIid(); + if(!this.invoicePositions[iid]) { + this.invoicePositions[iid] = [dayReport.getTitle(), 0.0, this.invoiceCurrencyPerHour * dayReport.getChargeRatio(), 0.0]; + } + this.invoicePositions[iid][1] += dayReport.getSpent(this.invoiceTimeMaxUnit) / 3600; + this.invoicePositions[iid][3] = Math.round((this.invoicePositions[iid][1] * this.invoicePositions[iid][2]) * 100) * 0.01; + } + ); + }); + + let invoiceTotal = this.invoicePositionExtraValue; + Object.keys(this.invoicePositions).forEach( + k => { + invoiceTotal += this.invoicePositions[k][3]; + } + ) + this.totalForInvoiceExkl = invoiceTotal; + this.totalForInvoiceMwst = Math.round(invoiceTotal * this.invoiceVAT * 100) * 0.01; + this.totalForInvoice = Math.round((this.totalForInvoiceExkl + this.totalForInvoiceMwst)/this.invoiceCurrencyMaxUnit)*this.invoiceCurrencyMaxUnit; + } + + + concat(data, separator) { + if(null == data) { + return ""; + } + if(!Array.isArray(data)) { + return data.replace(/_/g, " "); + } + let data2 = []; + data.forEach(el => data2.push(this.concat(el, separator))); + return data2.join(separator); + } + + makeStats() { + + let stats = ''; + + _.each(this.stats, (time, name) => stats += `\n* **${name}**: ${time}`); + stats += `\n`; + + if (this.projects.length > 1) { + _.each(this.projects, (time, name) => stats += `\n* **${name.red}**: ${time}`); + stats += `\n`; + } + // REMOVE + // _.each(this.users, (time, name) => stats += `\n* **${name}**: ${time}`); + let to = this.concat(this.config.get('invoiceAddress'), '
'); + 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, '
'); + + // QR bill + let endOfZipPos = this.config.get('invoiceSettings').from[3].search("[ _]"); + let zip = this.config.get('invoiceSettings').from[3].substring(0, endOfZipPos); + let city = this.config.get('invoiceSettings').from[3].substring(endOfZipPos + 1); + + // debitor + let nDebitorAddressFields = Array.isArray(this.config.get('invoiceAddress'))? this.config.get('invoiceAddress').length: -1; + let nameDebitor = ""; + let zipDebitor = ""; + let cityDebitor = ""; + let addressDebitor = ""; + let countryDebitor = "CH"; + let regexAllUnderscores = /_/g; + + if(nDebitorAddressFields > 0) { + nameDebitor = this.config.get('invoiceAddress') [0].replace(regexAllUnderscores, " "); + } + else { + nameDebitor = this.config.get('invoiceAddress').toString(); + } + + if(nDebitorAddressFields > 2) { + let endOfZipPosDebitor = this.config.get('invoiceAddress')[nDebitorAddressFields-1].search("[ _]"); + if(endOfZipPosDebitor > 0){ + zipDebitor = this.config.get('invoiceAddress')[nDebitorAddressFields-1].substring(0, endOfZipPosDebitor).replace(regexAllUnderscores, " "); + cityDebitor = this.config.get('invoiceAddress')[nDebitorAddressFields-1].substring(endOfZipPosDebitor + 1).replace(regexAllUnderscores, " "); + } + addressDebitor = this.config.get('invoiceAddress') [nDebitorAddressFields-2].replace(regexAllUnderscores, " "); + if(zipDebitor.search("-") > 0) + { + let countryZip = zipDebitor.split("-"); + countryDebitor = countryZip[0]; + zipDebitor = countryZip[1]; + } + } + + const data = { + currency: "CHF", + amount: this.totalForInvoice, + additionalInformation: this.config.get('invoiceReference'), + creditor: { + name: this.config.get('invoiceSettings').from[0], + address: this.config.get('invoiceSettings').from [2], + zip: zip, + city: city, + account: this.config.get('invoiceSettings').IBAN, + country: this.config.get('invoiceSettings').Country + }, + debtor: { + name: nameDebitor, + address: addressDebitor, + zip: zipDebitor, + city: cityDebitor, + country: countryDebitor + } + }; + const options = { + language: "DE" + }; + const svg = new SwissQRBill.SVG(data, options); + // make svg scalable, by adding viewBox and removing height/width attributes + svg.instance.viewBox(0,0,740,420) + svg.instance.height(""); + svg.instance.width(""); + let positions =""; + let positionIids = Object.keys(this.invoicePositions); + positionIids.sort(); + positionIids.forEach( + k => { + // text, total H, Rate, Total + let position = this.invoicePositions[k]; + positions += + `
${position[0]} (${position[1].toFixed(2)} Stunden zu ${position[2]} ${this.invoiceCurrency})
+
${this.invoiceCurrency} ${position[3].toFixed(2)}
`; + } + ); + + let extra = ""; + if(this.invoicePositionExtraValue > 0) { + extra = `
${this.invoicePositionExtraText}
+
${this.invoiceCurrency} ${this.invoicePositionExtraValue.toFixed(2)}
`; + } + + this.out += +`
${from}
+ +
+
${this.config.get('invoiceSettings').fromShort}
+
${to}
+
+
${this.config.get('invoiceDate')}
+ +# ${this.config.get('invoiceTitle')} + +${opening} + +
+${positions} +${extra} +
MWST (${this.invoiceVAT*100}%)
+
${this.invoiceCurrency} ${this.totalForInvoiceMwst.toFixed(2)}
+
Rechnungsbetrag inkl. MWST
+
${this.invoiceCurrency} ${this.totalForInvoice.toFixed(2)}
+
+ +${this.config.get('invoiceSettings').bankAccount} + +${closing} + + +
${svg.toString()}
` + + } + + makeIssues() { + } + + makeMergeRequests() { + } + + makeRecords() { + this.out += ` + +



Stundenrapport

`; + + let timesNew = [['Datum', 'Beschreibung', 'Stunden']]; + let daysNew = Object.keys(this.daysNew); + daysNew.sort(); + daysNew.forEach( + k => { + let day = this.daysNew[k]; + day.forEach( + (dayReport) => { + let notesLi = ""; + + dayReport.getNotes().forEach( + (note) => (notesLi+=`
  • ${note}
  • `)); + if(notesLi !== "") { + notesLi = `
      ${notesLi}
    `; + } + timesNew.push([dayReport.getDate().format(this.config.get('dateFormat')), + dayReport.getTitle() + notesLi, + this.config.toHumanReadable(dayReport.getSpent(this.invoiceTimeMaxUnit), 'records') + ]); + } + ); + }); + this.write(``); + + this.write(Table(timesNew, { align: ['l', 'l', 'r'] })); + + + + // 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'); + } + } + +} + +module.exports = invoice2; \ No newline at end of file From 6d38b88566b6e8632f3ccb911efb5aa14e81fb6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 29 Nov 2022 16:16:21 +0100 Subject: [PATCH 09/10] fixed gtt status on frames w/o note --- src/gtt-status.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gtt-status.js b/src/gtt-status.js index 6b1362c..6367a38 100755 --- a/src/gtt-status.js +++ b/src/gtt-status.js @@ -22,6 +22,6 @@ tasks.status() return; } - frames.forEach(frame => console.log(`Project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} "${frame.note}" is running, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); + frames.forEach(frame => console.log(`Project ${frame.project.magenta} ${frame.resource.type.blue} ${('#' + frame.resource.id).blue} ${frame.note?frame.note:''} is running, started ${moment(frame.start).fromNow().green} (id: ${frame.id})`)); }) .catch(error => Cli.error('Could not read frames.', error)); \ No newline at end of file From 8862152b2055eac3ac6a32c2a78fa5d98e82ef96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 29 Nov 2022 16:25:24 +0100 Subject: [PATCH 10/10] invoice changed position format --- src/output/invoice2.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/output/invoice2.js b/src/output/invoice2.js index 615a579..9f65031 100644 --- a/src/output/invoice2.js +++ b/src/output/invoice2.js @@ -29,13 +29,8 @@ class invoice2 extends Base { this.invoiceCurrencyMaxUnit = this.config.get('invoiceCurrencyMaxUnit'); this.invoiceTimeMaxUnit = this.config.get('invoiceTimeMaxUnit'); -/* - -
    ${this.invoicePositionText} (${this.totalhForInvoice.toFixed(2)} Stunden zu ${this.invoiceCurrencyPerHour} ${this.invoiceCurrency})
    -
    ${this.invoiceCurrency} ${this.totalForInvoiceH.toFixed(2)}
    -*/ this.invoicePositions = {}; //key=iid, value=[text, total H, Rate, Total] - + Object.keys(this.daysNew).forEach( k => { let day = this.daysNew[k]; @@ -165,15 +160,18 @@ class invoice2 extends Base { // text, total H, Rate, Total let position = this.invoicePositions[k]; positions += - `
    ${position[0]} (${position[1].toFixed(2)} Stunden zu ${position[2]} ${this.invoiceCurrency})
    -
    ${this.invoiceCurrency} ${position[3].toFixed(2)}
    `; +`
    ${position[0]}: ${position[1].toFixed(2)}h (${position[2]} ${this.invoiceCurrency}/h)
    +
    ${this.invoiceCurrency} ${position[3].toFixed(2)}
    +`; } ); let extra = ""; if(this.invoicePositionExtraValue > 0) { - extra = `
    ${this.invoicePositionExtraText}
    -
    ${this.invoiceCurrency} ${this.invoicePositionExtraValue.toFixed(2)}
    `; + extra = +`
    ${this.invoicePositionExtraText}
    +
    ${this.invoiceCurrency} ${this.invoicePositionExtraValue.toFixed(2)}
    +`; } this.out +=