commit 30367fca135381c7d9a29a97a6192ce107a39c29 Author: Maxime Prades Date: Thu Apr 10 18:09:27 2014 -0700 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b08cbca --- /dev/null +++ b/LICENSE @@ -0,0 +1,48 @@ +Copyright 2012 Zendesk + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ADDITIONAL TERMS AND CONDITIONS + +Capitalized terms used herein have the meaning set forth in the +Zendesk, Inc. (“Zendesk”) Terms of Service (available at www.zendesk.com/company/terms) +(the “Terms”). The software made available herein constitutes the source code for a +Zendesk Application (“Application Software”) which may be implemented to +enable features or functionality to be utilized in connection with a subscription +to the Service. Notwithstanding anything to the contrary set forth in the Terms +or any other agreement by and between you and Zendesk, Application Software is +provided “AS IS” and on an “AS AVAILABLE” basis, and that Zendesk makes no warranty +as to the Application Software. ZENDESK DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT AND THOSE ARISING FROM A COURSE +OF DEALING OR USAGE OF TRADE RELATED TO THE APPLICATION SOFTWARE, ITS USE OR ANY INABILITY +TO USE IT OR THE RESULTS OF ITS USE. + +Zendesk makes Zendesk Applications available for use through the user interface of +the Service in the manner described (for each Zendesk Application) at www.zendesk.com/apps +(“Service Implementation”). Use of Application Software, other than in connection with a +Service Implementation is subject to the following risks and conditions: (i) the Application +Software may contain errors, design flaws or other problems; and (ii) use of the Application +Software may result in unexpected results, loss of data, project delays or other unpredictable +damage or loss. Zendesk only supports the Application Software currently available and installed +through the Service Implementation. By utilizing Application Software other than in connection +with the Service Implementation, you are agreeing that Zendesk shall have no obligation to +correct any bugs, defects or errors in the Application Software you have utilized or +otherwise to support or maintain the Application Software you have utilized. + +If you elect to utilize any Application Software other than through a Service Implementation, +you are agreeing to release Zendesk from any claim with regard to the Application Software, +its operation, availability or its failure to operate or be available. +Without limiting the generality of the foregoing, You acknowledge and agree that neither the use, +availability nor operation of any Application Software shall be subject to any service level +commitment applicable to the Service. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0feea9 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +:warning: *Use of this software is subject to important terms and conditions as set forth in the License file* :warning: + +# Time Tracking App + +## Description: + +Helps you track time on your tickets. You'll be able to submit custom time and restrict time submission. + +## App location: + +* Ticket sidebar +* New Ticket sidebar + +## Features: + +* Track current spent time on a ticket. +* Track total spent time on a ticket. +* Ability to select in which unit to report the total time spent (milliseconds, seconds, minutes). +* Log every time submission (time submitted, agent name, status submitted, submission date). *Can be turned off*. +* Download time logs as a csv file. +* Ability to auto-pause/resume the timer when the agent isn't focused on the ticket. *Can be turned off*. +* Ability for the agent to manually pause/resume the timer. *Can be turned off*. +* Ability for the agent to restart the timer. *Can be turned off*. +* Ability for the agent to submit his own spent time. *Can be turned off*. + +## Set-up/installation instructions: + +You will need to create 2 ticket fields: +* A Numeric ticket field that will contain the total time spent (used to report on GoodData). +* A Multi-line text ticket field that will store the app configuration and time logs. + +After installation, on the app settings page: +* Put the previously create Numeric ticket field ID in the "Time Field ID" setting. +* Put the previously create Multi-line ticket field ID in the "Config Field ID" setting. +* Enable/Disable settings to customise as you need it. + +## Contribution: + +Pull requests are welcome. + +## Screenshot(s): + +Default view of the app: + +![](http://i.imgur.com/V1x1coZ.png) + diff --git a/app.css b/app.css new file mode 100644 index 0000000..3f41184 --- /dev/null +++ b/app.css @@ -0,0 +1,31 @@ +.current-timer { + font-weight: bold; +} + +.live-timer { + font-size: 15px; +} + +.text-center { + text-align: center; +} + +input { + text-align: inherit; + font-size: 20px; + padding: 10px; + margin: 10px; +} + +.timelogs-container { + display: none; +} + +table { + margin-bottom: none; +} + +.btn-group { + display: inline-block; + padding-top: 7px; +} \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..1dcb7ca --- /dev/null +++ b/app.js @@ -0,0 +1,342 @@ +(function() { + + 'use_strict'; + + return { + events: { + 'app.activated' : 'onAppActivated', + 'app.deactivated' : 'onAppFocusOut', + 'app.willDestroy' : 'onAppWillDestroy', + 'ticket.save' : 'onTicketSave', + 'ticket.form.id.changed' : 'onTicketFormChanged', + 'click .pause' : 'onPauseClicked', + 'click .resume' : 'onResumeClicked', + 'click .reset' : 'onResetClicked', + 'click .modal-save' : 'onModalSaveClicked', + 'click .timelogs-opener' : 'onTimeLogsContainerClicked', + 'shown .modal' : 'onModalShown', + 'hidden .modal' : 'onModalHidden' + }, + + /* + * + * EVENT CALLBACKS + * + */ + onAppActivated: function(app) { + if (app.firstLoad) { + _.defer(this.initialize.bind(this)); + } else { + this.onAppFocusIn(); + } + }, + + onAppWillDestroy: function() { + clearInterval(this.timeLoopID); + }, + + onAppFocusOut: function() { + if (this.setting('auto_pause_resume')) { + this.autoPause(); + } + }, + + onAppFocusIn: function() { + if (this.setting('auto_pause_resume') && + !this.manuallyPaused) { + this.autoResume(); + } + }, + + onTicketFormChanged: function() { + _.defer(this.hideFields.bind(this)); + }, + + onTicketSave: function() { + if (this.setting('time_submission')) { + return this.promise(function(done, fail) { + this.saveHookPromiseDone = done; + this.saveHookPromiseFail = fail; + + this.renderTimeModal(); + }.bind(this)); + } else { + this.updateTime(this.elapsedTime); + + return true; + } + }, + + onPauseClicked: function(e) { + var $el = this.$(e.currentTarget); + + $el.removeClass('pause').addClass('resume'); + $el.find('i').prop('class', 'icon-play'); + + this.manuallyPaused = this.paused = true; + }, + + onResumeClicked: function(e) { + var $el = this.$(e.currentTarget); + + $el.removeClass('resume').addClass('pause'); + $el.find('i').prop('class', 'icon-pause'); + + this.manuallyPaused = this.paused = false; + }, + + onResetClicked: function() { + this.elapsedTime = 0; + }, + + onTimeLogsContainerClicked: function(e) { + var $el = this.$(e.currentTarget); + + if (!this.$('.timelogs-container').is(':visible')) { + $el.addClass('active'); + this.$('.timelogs-container').show(); + } else { + $el.removeClass('active'); + this.$('.timelogs-container').hide(); + } + }, + + onModalSaveClicked: function() { + var timeString = this.$('.modal-time').val(); + + try { + this.updateTime(this.TimeHelper.timeStringToMs(timeString)); + this.saveHookPromiseIsDone = true; // Flag that saveHookPromiseDone is gonna be called after hiding the modal + this.$('.modal').modal('hide'); + this.saveHookPromiseDone(); + } catch (e) { + if (e.message == 'bad_time_format') { + services.notify(this.I18n.t('errors.bad_time_format'), alert); + } else { + throw e; + } + } + }, + + onModalShown: function() { + var timeout = 15, + $timeout = this.$('span.modal-timer'), + $modal = this.$('.modal'); + + this.modalTimeoutID = setInterval(function() { + timeout -= 1; + + $timeout.html(timeout); + + if (timeout === 0) { + $modal.modal('hide'); + } + }.bind(this), 1000); + }, + + onModalHidden: function() { + clearInterval(this.modalTimeoutID); + + if (!this.saveHookPromiseIsDone) { + this.saveHookPromiseFail(this.I18n.t('errors.save_hook')); + } + }, + + /* + * + * METHODS + * + */ + + initialize: function() { + this.hideFields(); + + this.time = this.getTimeObject(); + this.timeLoopID = this.setTimeLoop(); + var timelogs = this.TimeHelper.prettyTimeLogs(this.time.logs); + + this.switchTo('main', { + manual_pause_resume: this.setting('manual_pause_resume'), + timelogs: timelogs, + display_reset: this.setting('reset'), + display_timer: this.setting('display_timer'), + display_timelogs: this.setting('display_timelogs'), + timelogs_csv_filename: helpers.fmt('ticket-timelogs-%@', + this.ticket().id()), + timelogs_csv_string: encodeURI(this.timelogsToCsvString(timelogs)) + }); + + this.$('tr').tooltip({ placement: 'left', html: true }); + }, + + updateMainView: function(time) { + this.$('.live-timer').html(this.TimeHelper.msToTime(time)); + this.$('.live-totaltimer').html(this.TimeHelper.msToTime( + this.time.value + time + )); + }, + + hideFields: function() { + _.each([this.timeFieldLabel(), this.timeObjectFieldLabel()], function(f) { + var field = this.ticketFields(f); + + if (field) { + field.hide(); + } + }, this); + }, + + /* + * TIME RELATED + */ + + setTimeLoop: function() { + var interval = 1000; + + this.elapsedTime = 0; + + return setInterval(function() { + if (!this.paused) { + this.elapsedTime += interval; + + this.updateMainView(this.elapsedTime); + } + }.bind(this), interval); + }, + + updateTime: function(time) { + this.time.logs.push({ + time: time, + submitter_id: this.currentUser().id(), + submitter_name: this.currentUser().name(), + submitted_at: new Date().getTime(), + status: this.ticket().status() + }); + + this.time.value += time; + + this.saveTimeObject(); + }, + + saveTimeObject: function() { + this.ticket().customField( + this.timeFieldLabel(), + String(this.normalizeTimeForSave(this.time.value)) + ); + + this.ticket().customField( + this.timeObjectFieldLabel(), + JSON.stringify(this.time) + ); + }, + + autoResume: function() { + this.paused = false; + }, + + autoPause: function() { + this.paused = true; + }, + + renderTimeModal: function() { + this.$('.modal-time').val(this.TimeHelper.msToTime(this.elapsedTime)); + this.$('.modal').modal('show'); + }, + + /* + * + * HELPERS + * + */ + + timelogsToCsvString: function(logs) { + return _.reduce(logs, function(memo, log) { + return memo + helpers.fmt('%@\n', [ log.time, log.submitter_name, log.date_submitted_at, log.status ]); + }, 'Time,Submitter,Submitted At,status\n', this); + }, + + // Returns a new time in unit specified by the setting (mm|ss|ms) + normalizeTimeForSave: function(time) { + var timeUnits = { + "minute": 60000, + "second": 1000, + "millisecond": 1 + }, + exponent = timeUnits[this.setting('time_unit')] || timeUnits.second; + + + return Math.floor(time / exponent); + }, + + getTimeObject: function() { + var timeObject = this.ticket().customField(this.timeObjectFieldLabel()); + + if (timeObject) { + return JSON.parse(timeObject); + } else { + return { value: 0, logs: [] }; + } + }, + + timeObjectFieldLabel: function() { + return this.buidFieldLabel(this.setting('time_object_field_id')); + }, + + timeFieldLabel: function() { + return this.buidFieldLabel(this.setting('time_field_id')); + }, + + buidFieldLabel: function(id) { + return helpers.fmt('custom_field_%@', id); + }, + + TimeHelper: { + msToTime: function(millis) { + var time = parseInt(millis / 1000, 10), + seconds = time % 60; + + time = parseInt(time / 60, 10); + + var minutes = time % 60, + hours = parseInt(time / 60, 10) % 24; + + return helpers.fmt('%@:%@:%@', + this.addInsignificantZero(hours), + this.addInsignificantZero(minutes), + this.addInsignificantZero(seconds)); + }, + + timeStringToMs: function(str) { + var re = /^([\d]{2}):([\d]{2}):([\d]{2})$/, + result = re.exec(str); + + if (!result || + result.length != 4) { + throw { message: 'bad_time_format' }; + } else { + return (parseInt(result[1], 10) * 3600000) + + (parseInt(result[2], 10) * 60000) + + (parseInt(result[3], 10) * 1000); // hours + minutes + seconds in milliseconds + } + }, + + addInsignificantZero: function(n) { + return ( n < 10 ? '0' : '') + n; + }, + + prettyTimeLogs: function(logs) { + return _.reduce(logs, function(memo, log) { + var logDecorator = _.clone(log), + submitted_at = new Date(log.submitted_at); + + logDecorator.date_submitted_at = submitted_at.toLocaleString(); + logDecorator.time = this.msToTime(log.time); + + memo.push(logDecorator); + + return memo; + }, [], this); + } + } + }; +}()); diff --git a/assets/logo-small.png b/assets/logo-small.png new file mode 100644 index 0000000..d0dfcdf Binary files /dev/null and b/assets/logo-small.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..07964ae Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/promotion-image.png b/assets/promotion-image.png new file mode 100644 index 0000000..a79e6c6 Binary files /dev/null and b/assets/promotion-image.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..7ea6605 --- /dev/null +++ b/manifest.json @@ -0,0 +1,63 @@ +{ + "name": "Time Tracking", + "author": { + "name": "Zendesk", + "email": "support@zendesk.com", + "url": "https://www.zendesk.com" + }, + + "defaultLocale": "en", + "private": false, + "singleInstall": true, + "location": [ "new_ticket_sidebar", "ticket_sidebar" ], + "frameworkVersion": "1.0", + "version": "0.1", + + "parameters": [ + { + "name": "time_field_id", + "type": "number", + "required": true + }, + { + "name": "time_object_field_id", + "type": "number", + "required": true + }, + { + "name": "display_timer", + "type": "checkbox", + "default": true + }, + { + "name": "display_timelogs", + "type": "checkbox", + "default": true + }, + { + "name": "auto_pause_resume", + "type": "checkbox", + "default": true + }, + { + "name": "manual_pause_resume", + "type": "checkbox", + "default": true + }, + { + "name": "reset", + "type": "checkbox", + "default": true + }, + { + "name": "time_submission", + "type": "checkbox", + "default": true + }, + { + "name": "time_unit", + "type": "text", + "default": "second" + } + ] +} diff --git a/templates/layout.hdbs b/templates/layout.hdbs new file mode 100644 index 0000000..f89542d --- /dev/null +++ b/templates/layout.hdbs @@ -0,0 +1,5 @@ +
+
+
diff --git a/templates/main.hdbs b/templates/main.hdbs new file mode 100644 index 0000000..0426397 --- /dev/null +++ b/templates/main.hdbs @@ -0,0 +1,81 @@ +
+
+
+ {{t "views.main.current_time_spent"}}: +
+
+
00:00:00
+
+
+ +
+
+ {{t "views.main.total_time_spent"}}: +
+
+
00:00:00
+
+
+
+ +
+
+ {{#if manual_pause_resume}} + + {{/if}} + {{#if display_reset}} + + {{/if}} + {{#if display_timelogs}} + {{#if timelogs}} + {{t "views.main.timelogs"}} + {{/if}} + {{/if}} +
+
+ +{{#if timelogs}} +
+ + + + + + + + {{#each timelogs}} + + + + + + {{/each}} + +
{{t "views.main.timelogs_table.agent"}}{{t "views.main.timelogs_table.time"}}{{t "views.main.timelogs_table.status"}}
{{submitter_name}}{{time}} + + {{status}} + +
+ +
+{{/if}} + + diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..7bdf70e --- /dev/null +++ b/translations/en.json @@ -0,0 +1,68 @@ +{ + "app": { + "description": "Helps you track time spent on tickets.", + "name": "Time Tracking", + "parameters": { + "time_field_id": { + "label": "Time Field ID", + "helpText": "The ID of a custom numeric field that will hold the total time." + }, + "time_object_field_id": { + "label": "Config Field ID", + "helpText": "The ID of a custom multi-line field that will hold the config/timelogs." + }, + "display_timer": { + "label": "Display time", + "helpText": "Agent can see the timers (current/total)" + }, + "display_timelogs": { + "label": "Display Timelogs", + "helpText": "Agent can see timelogs." + }, + "auto_pause_resume": { + "label": "Auto Pause/Resume", + "helpText": "The time will pause when the focus on the ticket is lost and resume itself as soon as the agent is back on the ticket." + }, + "manual_pause_resume": { + "label": "Manual Pause/Resume/Reset", + "helpText": "Agent can manually pause/resume/reset the timer." + }, + "reset": { + "label": "Reset current time", + "helpText": "Agent can reset his current time spent on a ticket." + }, + "time_submission": { + "label": "Time submission", + "helpText": "Agent submit his own time." + }, + "time_unit": { + "label": "Unit of time", + "helpText": "Choose in which unit the time will be saved. You can choose millisecond/second/minute. Defaults to second." + } + } + }, + "views": { + "main": { + "pause": "Pause", + "current_time_spent": "Current Time Spent", + "total_time_spent": "Total Time Spent", + "pause_resume": "Pause/Resume", + "reset": "Reset", + "timelogs": "Timelogs", + "timelogs_table": { + "agent": "Agent", + "time": "Time", + "status": "Status" + } + }, + "modal": { + "body": "The system shows that you spent the following time on this ticket, you can either edit it or leave it as is and click \"Save Ticket\".", + "close": "Close", + "save": "Save Ticket" + } + }, + "errors": { + "save_hook": "You need to submit a time before saving this ticket!", + "bad_time_format": "Bad time format (HH:MM:SS)." + } +}