angular.module('timeTracker.account', ['ui.router', 'logger', 'ui.bootstrap', 'timeTracker.filters']) .config([ '$stateProvider', function ($stateProvider) { 'use strict'; $stateProvider.state('login', { title: 'Login', url: '/login?team', reloadOnSearch: false, controller: 'LoginController as login', templateUrl: 'app/account/login.html', data: { accessRequires: 'loggedOut' } }); $stateProvider.state('signup', { title: 'Signup', url: '/signup?team', reloadOnSearch: false, controller: 'SignupController as signup', templateUrl: 'app/account/signup.html', data: { accessRequires: 'loggedOut' } }); $stateProvider.state('manage-account', { title: 'Manage Account', url: '/manage-account?team', reloadOnSearch: false, controller: 'ManageAccountController as account', templateUrl: 'app/account/manage-account.html', data: { accessRequires: 'loggedIn' } }); $stateProvider.state('forgot-password', { title: 'Forgot Password', url: '/forgot-password?team', reloadOnSearch: false, controller: 'ForgotPasswordController as forgotPassword', templateUrl: 'app/account/forgot-password.html', data: { accessRequires: 'loggedOut' } }); $stateProvider.state('set-password', { title: 'Set Password', url: '/set-password?team', reloadOnSearch: false, controller: 'SetPasswordController as setPassword', templateUrl: 'app/account/set-password.html', data: { accessRequires: 'loggedOut' } }); // obtain refresh token after token lifetime ended $stateProvider.state('refresh', { url: '/refresh?team' }); $stateProvider.state('subscription', { title: 'Subscription', url: '/subscription?team', reloadOnSearch: false, controller: 'SubscriptionController as subscription', templateUrl: 'app/account/subscription/subscription.html', data: { accessRequires: 'loggedIn' } }); } ]); angular.module('timeTracker.account') .controller('AccountController', [ 'authService', '$state', 'user', 'userService', '$scope', function (authService, $state, user, userService, $scope) { this.user = user; this.isAuth = function () { return authService.authentication && authService.authentication.isAuth; }; this.showTeamsDropdown = function () { return user.teams && user.teams.length > 1 || user.numberOfActiveTeams === 0; }; this.logOff = function () { authService.logOut(); $state.go('login', authService.getTeamParam()); }; this.selectTeam = function (team) { if (user.currentTeam && user.currentTeam.id !== team.id) { userService.setCurrentTeam(team); } }; $scope.isTracking = function () { return user.currentTeam.userIsTracking; }; } ]); angular.module('timeTracker.account') .factory('authInterceptorService', [ '$q', '$injector', '$location', 'localStorageService', 'logger', function ($q, $injector, $location, localStorageService, logger) { 'use strict'; var authInterceptorServiceFactory = {}; var request = function(config) { config.headers = config.headers || {}; var authData = localStorageService.get('authorizationData'); if (authData) { config.headers.Authorization = 'Bearer ' + authData.token; } return config; }; // follows this approach: // http://engineering.talis.com/articles/elegant-api-auth-angular-js/ var responseError = function(response) { if (response.status === 401) { logger.debug('401 - on response', response.data); var authService = $injector.get('authService'); var authData = localStorageService.get('authorizationData'); // if 401 with invalid_token if (authData && authData.useRefreshTokens) { // defer until we can re-request a new token var deferred = $q.defer(); logger.debug('Try to refresh access token'); // try to refresh access token authService.refreshToken() .then(function () { logger.debug('Access token successfully renewed - try to resend request', response.config); // resend request $injector.get('$http')(response.config) .then(function (resp) { logger.debug('Last failed request successfully repeated', resp); // we have a successful response return deferred.resolve(resp); }, function (err) { logger.errorHidden('Last failed request could not be repeated', err, err.status); // the last request could not be repeated return deferred.reject(err); }); }, function (err) { logger.errorHidden('Access token could not be refreshed after 401 Error', err, err.status); deferred.reject(err); // access token could not be refreshed }); return deferred.promise; // return the deferred promise } else { authService.logOut(); $location.path('/login'); } } return $q.reject(response); }; authInterceptorServiceFactory.request = request; authInterceptorServiceFactory.responseError = responseError; return authInterceptorServiceFactory; } ]); angular.module('timeTracker.account') .factory('authService', [ '$http', '$q', 'localStorageService', 'userService', 'logger', '$state', '$location', function ($http, $q, localStorageService, userService, logger, $state, $location) { 'use strict'; // ReSharper disable InconsistentNaming // ReSharper disable VariableUsedInInnerScopeBeforeDeclared var clientId = 'TimeTrackerWeb'; var _authentication = { isAuth: false, userName: null, useRefreshTokens: true }; var _externalAuthData = { provider: '', email: '', externalAccessToken: '' }; var _registerNewUser = function (credentials) { _logOut(); return $http.post('api/account/register', credentials); }; var getTeamParam = function () { var team = $location.search().team; return team != null ? { team: team } : {}; }; var setAuthorizationData = function (response) { var authData = { token: response.access_token, userName: response.userName, refreshToken: response.refresh_token, deviceId: response.deviceId, useRefreshTokens: true }; localStorageService.set('authorizationData', authData); }; var _login = function (loginData) { var deferred = $q.defer(), data = 'grant_type=password&username=' + encodeURIComponent(loginData.userName) + '&password=' + encodeURIComponent(loginData.password) + '&client_id=' + clientId, config = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }; $http.post('token', data, config) .success(function (response) { setAuthorizationData(response); _authentication.isAuth = true; _authentication.userName = loginData.userName; _authentication.useRefreshTokens = true; userService.refreshUser(); deferred.resolve(response); }) .error(function (err) { _logOut(); deferred.reject(err); }); return deferred.promise; }; var _logOut = function () { localStorageService.remove('authorizationData'); _authentication.isAuth = false; _authentication.userName = null; _authentication.useRefreshTokens = true; userService.resetUser(); }; var _fillAuthData = function () { var authData = localStorageService.get('authorizationData'); if (authData && authData.userName) { _authentication.isAuth = true; _authentication.userName = authData.userName; _authentication.useRefreshTokens = authData.useRefreshTokens; userService.refreshUser(); } }; var _refreshTokenDeferred = null; var _refreshToken = function () { // do not refresh token when refresh token call is running if (_refreshTokenDeferred) { return _refreshTokenDeferred.promise; } _refreshTokenDeferred = $q.defer(); var authData = localStorageService.get('authorizationData'); if (authData) { if (authData.useRefreshTokens) { var data = 'grant_type=refresh_token&refresh_token=' + authData.refreshToken + '&client_id=' + clientId + '&deviceId=' + authData.deviceId, config = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }; $http.post('token', data, config) .success(function (response) { setAuthorizationData(response); logger.debug('Token was refreshed!'); _refreshTokenDeferred.resolve(response); _refreshTokenDeferred = null; }) .error(function (err) { var logData = { response: err, authData: localStorageService.get('authorizationData') }; var title = 'Access token could not be refreshed - ' + logData.authData.userName; logger.errorHidden(title, logData, err.status); localStorageService.remove('authorizationData'); _logOut(); $state.go('login'); _refreshTokenDeferred.reject(err); _refreshTokenDeferred = null; toastr.error('Please login again', err.status); }); } } else { _refreshTokenDeferred.reject('Auth Data not found. Access token cannot be refreshed.'); var promise = _refreshTokenDeferred.promise; _refreshTokenDeferred = null; return promise; } return _refreshTokenDeferred.promise; }; var _changePassword = function (credentials) { return $http.post('api/account/change-password', credentials); }; var _setPassword = function (setPasswordData) { return $http.post('api/account/reset-password', setPasswordData); }; var _forgotPassword = function (emailAddress) { var emailData = { email: emailAddress }; return $http.post('api/account/forgot-password', emailData); }; var setErrors = function (obj, response) { for (var key in response.data.modelState) { if (response.data.modelState.hasOwnProperty(key)) { for (var i = 0; i < response.data.modelState[key].length; i++) { obj.errors.push(response.data.modelState[key][i]); } } } }; return { registerNewUser: _registerNewUser, logIn: _login, logOut: _logOut, fillAuthData: _fillAuthData, authentication: _authentication, refreshToken: _refreshToken, externalAuthData: _externalAuthData, changePassword: _changePassword, setPassword: _setPassword, forgotPassword: _forgotPassword, clientId: clientId, setErrors: setErrors, getTeamParam: getTeamParam }; // ReSharper restore InconsistentNaming // ReSharper restore VariableUsedInInnerScopeBeforeDeclared } ]); angular.module('timeTracker.account') .controller('ChangePasswordController', [ 'authService', 'user', 'logger', '$modalInstance', function (authService, user, logger, $modalInstance) { var changePassword = this; changePassword.user = user; changePassword.savedSuccessfully = false; changePassword.isLoading = false; changePassword.message = ''; changePassword.errors = []; changePassword.credentials = { oldPassword: '', newPassword: '' }; changePassword.changePassword = function () { changePassword.isLoading = true; changePassword.message = ''; changePassword.errors = []; authService.changePassword(changePassword.credentials) .then(function () { changePassword.savedSuccessfully = true; logger.success('Your new password has been saved.'); $modalInstance.close(); }, function (response) { for (var key in response.data.modelState) { if (response.data.modelState.hasOwnProperty(key)) { for (var i = 0; i < response.data.modelState[key].length; i++) { changePassword.errors.push(response.data.modelState[key][i]); } } } changePassword.message = 'Failed to change password due to: '; changePassword.savedSuccessfully = false; }) .finally(function () { changePassword.isLoading = false; }); }; changePassword.cancel = function() { $modalInstance.close(); }; } ]); angular.module('timeTracker.account') .directive('ttChangePassword', [ '$uibModal', function ($uibModal) { function link(scope, element) { element.bind('click', function() { $uibModal.open({ animation: true, templateUrl: 'app/account/change-password.html', controller: 'ChangePasswordController as changePassword', size: 'lg' }); }); } return { restrict: 'A', link: link }; } ]); angular.module('timeTracker.account') .service('emailAddressService', [ function () { 'use strict'; var emailAddress = null; return { get: function () { return emailAddress; }, set: function (value) { emailAddress = value; } }; } ]); angular.module('timeTracker.account') .controller('ForgotPasswordController', [ '$location', '$timeout', 'emailAddressService', 'authService', function ($location, $timeout, emailAddressService, authService) { var self = this; self.emailAddress = emailAddressService.get(); self.sentSuccessfully = false; self.isLoading = false; self.message = ''; self.errors = []; self.forgotPassword = function() { self.message = ''; self.errors = []; self.isLoading = true; authService.forgotPassword(self.emailAddress) .then(function () { self.sentSuccessfully = true; self.message = 'We have just sent you a recovery link to this email - in case this email is registered.'; }, function (response) { self.sentSuccessfully = false; for (var key in response.data.modelState) { if (response.data.modelState.hasOwnProperty(key)) { for (var i = 0; i < response.data.modelState[key].length; i++) { self.errors.push(response.data.modelState[key][i]); } } } self.message = 'Failed to reset password due to: '; }) .finally(function () { self.isLoading = false; }); }; } ]); angular.module('timeTracker.account') .value('lastUsed', { allTeamUsedProjects: null, allTeamUsedTags: null, lastOwnUsedProjects: null, lastOwnUsedTags: null }); angular.module('timeTracker.account') .controller('LoginController', [ '$scope', '$state', 'authService', 'emailAddressService', function ($scope, $state, authService, emailAddressService) { var self = this; self.loginData = { userName: '', password: '', useRefreshTokens: false }; self.message = ''; self.isLoading = false; self.login = function () { self.isLoading = true; authService.logIn(this.loginData) .then(function () { $state.go('track', authService.getTeamParam()); }, function (response) { self.message = response.error_description; }) .finally(function () { self.isLoading = false; }); }; self.storeEmailAddress = function () { emailAddressService.set(this.loginData.userName); }; } ]); angular.module('timeTracker.account') .controller('ManageAccountController', [ 'userService', 'user', function (userService, user) { var self = this; self.user = user; self.savedSuccessfully = false; self.message = ''; self.names = { firstName: user.firstName, lastName: user.lastName }; self.isLoading = false; userService.onUserChanged(function () { self.names.firstName = user.firstName; self.names.lastName = user.lastName; }); self.changeNames = function () { self.message = ''; self.errors = []; self.isLoading = true; userService.changeNames(self.names) .then(function () { self.savedSuccessfully = true; self.message = 'Names have been successfully saved.'; }, function (error) { if (error && error.message) { self.message = error.message; } else { self.message = "The names could not be saved: unknown error."; } self.savedSuccessfully = false; }) .finally(function () { self.isLoading = false; }); }; } ]); angular.module('timeTracker.account') .controller('SetPasswordController', [ '$location', '$state', '$timeout', 'authService', 'userService', function ($location, $state, $timeout, authService, userService) { var setPassword = this, disableForm = function() { setPassword.isEnabled = false; setPassword.message = 'The password cannot be set. The set password link may not be valid.'; }, setUserData = function() { setPassword.userData = {}; setPassword.userData.userId = ($location.search()).userId; setPassword.userData.token = ($location.search()).code; if (!setPassword.userData.userId || !setPassword.userData.token) { disableForm(); } userService.getEmailByUserId(setPassword.userData.userId) .then(function (response) { setPassword.email = response.data; }, function () { disableForm(); }); }, startTimer = function() { var timer = $timeout(function () { $timeout.cancel(timer); $state.go('track'); }, 3000); }; // not possible when query string parameters are missing // or user cannot be loaded setPassword.isEnabled = true; setPassword.savedSuccessfully = false; setPassword.message = ''; setPassword.errors = []; setPassword.isLoading = false; setPassword.passwordIsValid = function () { return setPassword.userData.password && setPassword.userData.password.length >= 6; }; setPassword.save = function () { setPassword.message = ''; setPassword.errors = []; setPassword.isLoading = true; authService.setPassword(setPassword.userData) .then(function () { setPassword.savedSuccessfully = true; setPassword.message = 'Password has been set successfully. You will be logged in automatically and forwarded to the track page in a few seconds.'; // log in right after saving new password authService.logIn({ userName: setPassword.email, password: setPassword.userData.newPassword }); startTimer(); }, function (response) { authService.setErrors(setPassword, response); setPassword.message = 'Failed to set password: '; }) .finally(function () { setPassword.isLoading = false; }); }; setUserData(); } ]); angular.module('timeTracker.account') .controller('SignupController', [ '$state', '$timeout', 'authService', function ($state, $timeout, authService) { var self = this; self.savedSuccessfully = false; self.message = ''; self.errors = []; self.credentials = { email: '', password: '' }; self.isLoading = false; var startTimer = function() { var timer = $timeout(function() { $timeout.cancel(timer); $state.go('track'); }, 3000); }; self.signUp = function() { self.message = ''; self.errors = []; self.isLoading = true; authService.registerNewUser(self.credentials) .then(function () { self.savedSuccessfully = true; self.message = 'User has been registered successfully.'; // log in right after registering authService.logIn({ userName: self.credentials.email, password: self.credentials.password }); startTimer(); }, function(response) { authService.setErrors(self, response); self.message = 'Failed to register user due to: '; }) .finally(function () { self.isLoading = false; }); }; } ]); angular.module('timeTracker.account') .controller('SubscriptionController', [ function () { 'use strict'; var clientToken = 'eyJ2ZXJzaW9uIjoyLCJhdXRob3JpemF0aW9uRmluZ2VycHJpbnQiOiJkMDg5NmNlM2JkMTI3OTQ1YzI0YzJlZjUzOWZlMzE5OGNlNWUzYzU1ZGJkZTlmZTUxNGY5ZmM1NTM3MDgxNTA4fGNyZWF0ZWRfYXQ9MjAxNy0xMC0zMFQxMzozMTowMi42Njc4MDkxNjYrMDAwMFx1MDAyNm1lcmNoYW50X2lkPTM0OHBrOWNnZjNiZ3l3MmJcdTAwMjZwdWJsaWNfa2V5PTJuMjQ3ZHY4OWJxOXZtcHIiLCJjb25maWdVcmwiOiJodHRwczovL2FwaS5zYW5kYm94LmJyYWludHJlZWdhdGV3YXkuY29tOjQ0My9tZXJjaGFudHMvMzQ4cGs5Y2dmM2JneXcyYi9jbGllbnRfYXBpL3YxL2NvbmZpZ3VyYXRpb24iLCJjaGFsbGVuZ2VzIjpbXSwiZW52aXJvbm1lbnQiOiJzYW5kYm94IiwiY2xpZW50QXBpVXJsIjoiaHR0cHM6Ly9hcGkuc2FuZGJveC5icmFpbnRyZWVnYXRld2F5LmNvbTo0NDMvbWVyY2hhbnRzLzM0OHBrOWNnZjNiZ3l3MmIvY2xpZW50X2FwaSIsImFzc2V0c1VybCI6Imh0dHBzOi8vYXNzZXRzLmJyYWludHJlZWdhdGV3YXkuY29tIiwiYXV0aFVybCI6Imh0dHBzOi8vYXV0aC52ZW5tby5zYW5kYm94LmJyYWludHJlZWdhdGV3YXkuY29tIiwiYW5hbHl0aWNzIjp7InVybCI6Imh0dHBzOi8vY2xpZW50LWFuYWx5dGljcy5zYW5kYm94LmJyYWludHJlZWdhdGV3YXkuY29tLzM0OHBrOWNnZjNiZ3l3MmIifSwidGhyZWVEU2VjdXJlRW5hYmxlZCI6dHJ1ZSwicGF5cGFsRW5hYmxlZCI6dHJ1ZSwicGF5cGFsIjp7ImRpc3BsYXlOYW1lIjoiQWNtZSBXaWRnZXRzLCBMdGQuIChTYW5kYm94KSIsImNsaWVudElkIjpudWxsLCJwcml2YWN5VXJsIjoiaHR0cDovL2V4YW1wbGUuY29tL3BwIiwidXNlckFncmVlbWVudFVybCI6Imh0dHA6Ly9leGFtcGxlLmNvbS90b3MiLCJiYXNlVXJsIjoiaHR0cHM6Ly9hc3NldHMuYnJhaW50cmVlZ2F0ZXdheS5jb20iLCJhc3NldHNVcmwiOiJodHRwczovL2NoZWNrb3V0LnBheXBhbC5jb20iLCJkaXJlY3RCYXNlVXJsIjpudWxsLCJhbGxvd0h0dHAiOnRydWUsImVudmlyb25tZW50Tm9OZXR3b3JrIjp0cnVlLCJlbnZpcm9ubWVudCI6Im9mZmxpbmUiLCJ1bnZldHRlZE1lcmNoYW50IjpmYWxzZSwiYnJhaW50cmVlQ2xpZW50SWQiOiJtYXN0ZXJjbGllbnQzIiwiYmlsbGluZ0FncmVlbWVudHNFbmFibGVkIjp0cnVlLCJtZXJjaGFudEFjY291bnRJZCI6ImFjbWV3aWRnZXRzbHRkc2FuZGJveCIsImN1cnJlbmN5SXNvQ29kZSI6IlVTRCJ9LCJtZXJjaGFudElkIjoiMzQ4cGs5Y2dmM2JneXcyYiIsInZlbm1vIjoib2ZmIn0='; braintree.setup(clientToken, 'dropin', { container: 'dropin-container', paypal: { singleUse: true, amount: 4.99, currency: 'EUR' }, onPaymentMethodReceived: function (reponse) { }, onError: function (error) { } }); } ]); angular.module('timeTracker.account') .controller('AddTeamController', [ '$modalInstance', 'teamService', 'logger', function ($modalInstance, teamService, logger) { var self = this; self.newTeamName = ''; self.isLoading = false; self.message = ''; self.nameIsValid = function() { return self.newTeamName && self.newTeamName.length > 0; }; self.ok = function() { self.isLoading = true; teamService.createTeam(self.newTeamName) .then(function () { logger.success('The team was saved successfully and can now be used for tracking.'); $modalInstance.close(); }, function (err) { if (err.data && err.data.message) { self.message = err.data.message; } else { self.message = "The team could not be saved: unknown error."; } }) .finally(function () { self.isLoading = false; }); }; self.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.account') .directive('ttAddTeam', [ '$uibModal', function($uibModal) { function link(scope, element) { element.bind('click', function() { $uibModal.open({ animation: true, templateUrl: 'app/account/teams/add-team.html', controller: 'AddTeamController as addTeam', size: 'lg' }); }); } return { restrict: 'A', link: link }; } ]); angular.module('timeTracker.account') .controller('AddTeamMemberController', [ '$modalInstance', 'teamService', 'logger', 'teamName', 'teamId', function($modalInstance, teamService, logger, teamName, teamId) { var addMember = this, concatUsers = function (users) { return users.join(', '); }; if (teamId == null) { logger.debug('Team members cannot be added when the "team" is not set.'); $modalInstance.close(); } addMember.name = teamName; addMember.emails = ''; addMember.isLoading = false; addMember.message = ''; addMember.nameIsValid = function () { return addMember.emails && addMember.emails.length > 0; }; addMember.ok = function () { addMember.isLoading = true; addMember.message = ''; teamService.addTeamMembers(teamId, addMember.emails) .then(function (response) { var result = response.data; if (result.usersAddedToTeam && result.usersAddedToTeam.length > 0) { var addedUsers = concatUsers(result.usersAddedToTeam); logger.success('The following users were added to the team and it was tried to send an invitation email to them: ' + addedUsers); } if (result.usersAlreadyInTeam && result.usersAlreadyInTeam.length > 0) { var alreadyMembers = concatUsers(result.usersAlreadyInTeam); logger.info('The following users were already team members, a new invitation was send to them: ' + alreadyMembers); } if (result.emailsNotDelivered && result.emailsNotDelivered.length > 0) { var notDeliveredEmails = concatUsers(result.emailsNotDelivered); logger.error('The following users could not be informed by email because there were errors sending the message to the recipient: ' + notDeliveredEmails); } if (result.invalidEmailAddresses && result.invalidEmailAddresses.length > 0) { var invalidEmails = concatUsers(result.invalidEmailAddresses); addMember.message += 'The following email addresses were not valid: ' + invalidEmails + '\r\n'; } else { $modalInstance.close(); } }, function (err) { logger.debug(err); if (err.data && err.data.message) { addMember.message = err.data.message; } else { addMember.message = "The team member(s) could not be saved: unknown error."; } }) .finally(function () { addMember.isLoading = false; }); }; addMember.cancel = function () { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.account') .directive('ttAddTeamMember', [ '$uibModal', function($uibModal) { function link(scope, element, attributes) { element.bind('click', function () { $uibModal.open({ animation: true, templateUrl: 'app/account/teams/add-team-member.html', controller: 'AddTeamMemberController as addMember', size: 'lg', resolve: { teamName: function() { return attributes.teamName; }, teamId: function () { return attributes.teamId; } } }); }); } return { restrict: 'A', link: link }; } ]); angular.module('timeTracker.account') .controller('EditTeamController', [ 'teamService', '$scope', 'logger', 'user', function (teamService, $scope, logger, user) { 'use strict'; var editTeam = this, updateUserCanEdit = function () { editTeam.userCanEdit = false; for (var i = 0; i < user.teams.length; i++) { var team = user.teams[i]; if (team.id === editTeam.team.id) { if (team.userIsActive && team.userIsAdmin) { editTeam.userCanEdit = true; } break; } } }, updateCurrentTeam = function () { teamService.loadTeam(editTeam.team.id) .then(function (response) { editTeam.team = response.data; updateUserCanEdit(); }); }, updateHasMembersToShow = function () { editTeam.hasMembersToShow = false; var shouldBeActive = editTeam.tab === 0, members = editTeam.team.members; for (var i = 0; i < members.length; i++) { if (members[i].isActive === shouldBeActive) { editTeam.hasMembersToShow = true; break; } } }, setMemberActive = function (email, status) { var members = editTeam.team.members; for (var i = 0; i < members.length; i++) { if (members[i].email === email) { members[i].isActive = status; break; } } }, deregisterOnTeamMembersChanged = teamService.onTeamMembersChanged(updateCurrentTeam), deregisterOnTeamsChanged = teamService.onTeamsChanged(updateCurrentTeam), loadPage = function () { editTeam.team = teamService.getTeamToEdit(); updateUserCanEdit(); editTeam.setTab(0); }; editTeam.setTab = function (tabId) { editTeam.tab = tabId; updateHasMembersToShow(); }; editTeam.canEditMember = function (member) { return editTeam.userCanEdit && user.email !== member.email; }; editTeam.makeAdmin = function (member) { member.isLoading = true; teamService.makeAdmin(editTeam.team.id, member.email) .then(function () { member.isAdmin = true; }) .finally(function () { member.isLoading = false; }); }; editTeam.makeMember = function (member) { member.isLoading = true; teamService.makeMember(editTeam.team.id, member.email) .then(function () { member.isAdmin = false; }) .finally(function () { member.isLoading = false; }); }; editTeam.resendInvitation = function (member) { member.isLoading = true; teamService.resendInvitation(editTeam.team.id, member.email) .then(function () { logger.success('An invitation email was send to member with email ' + member.email); }, function (error) { logger.error('The invitation could not be send to member with email ' + member.email + ' (' + error.status + ')!'); }) .finally(function () { member.isLoading = false; }); }; editTeam.deactivateMember = function (member) { member.isLoading = true; teamService.deactivateMember(editTeam.team.id, member.email) .then(function () { logger.success('The member with email ' + member.email + ' was deactivated.'); setMemberActive(member.email, false); updateHasMembersToShow(); },function (error) { logger.error('The member with email ' + member.email + ' could not be deactivated (' + error.status + ')!'); }) .finally(function () { member.isLoading = false; }); }; editTeam.reactivateMember = function (member) { member.isLoading = true; teamService.reactivateMember(editTeam.team.id, member.email) .then(function () { logger.success('The member with email ' + member.email + ' was reactivated.'); setMemberActive(member.email, true); updateHasMembersToShow(); }, function (error) { logger.error('The member with email ' + member.email + ' could not be reactivated (' + error.status + ')!'); }) .finally(function () { member.isLoading = false; }); }; loadPage(); $scope.$on('$destroy', function () { deregisterOnTeamMembersChanged(); deregisterOnTeamsChanged(); }); } ]); angular.module('timeTracker.account') .controller('ManageTeamsController', [ 'teamService', 'user', '$scope', function (teamService, user, $scope) { var manageTeams = this; manageTeams.teams = []; manageTeams.user = user; var updateTeams = function () { teamService.getTeamsToEdit().then(function (response) { manageTeams.teams = response.data; }); }; var deregisterTeamMembersChanged = teamService.onTeamMembersChanged(updateTeams); var deregisterTeamsChanged = teamService.onTeamsChanged(updateTeams); $scope.$on('$destroy', function () { deregisterTeamMembersChanged(); deregisterTeamsChanged(); }); updateTeams(); // initially, reset the teamToEdit instance teamService.setTeamToEdit(null); manageTeams.editTeam = function (team) { teamService.setTeamToEdit(team); }; manageTeams.currentUserCanEditTeam = function (teamId) { for (var i = 0; i < user.teams.length; i++) { var team = user.teams[i]; if (team.id === teamId) { if (team.userIsAdmin && team.userIsActive) { return true; } break; } } return false; }; } ]); angular.module('timeTracker.account') .controller('RemoveTeamController', [ '$modalInstance', 'teamService', 'userService', 'logger', 'teamId', 'teamName', function($modalInstance, teamService, userService, logger, teamId, teamName) { var removeTeam = this; removeTeam.teamName = teamName; removeTeam.message = ''; removeTeam.isLoading = false; removeTeam.ok = function() { removeTeam.isLoading = true; teamService.removeTeam(teamId) .then(function () { userService.refreshUser(); teamService.teamsChanged(); logger.success('The team was removed successfully.'); $modalInstance.close(); }, function (err) { if (err.data && err.data.message) { removeTeam.message = err.data.message; } else { removeTeam.message = 'The team could not be removed: unknown error.'; } }) .finally(function () { removeTeam.isLoading = false; }); }; removeTeam.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.account') .directive('ttRemoveTeam', [ '$uibModal', function($uibModal) { function link(scope, element, attributes) { element.bind('click', function() { $uibModal.open({ animation: true, templateUrl: 'app/account/teams/remove-team.html', controller: 'RemoveTeamController as removeTeam', size: 'lg', resolve: { teamId: function() { return attributes.teamId; }, teamName: function() { return attributes.teamName; } } }); }); } return { restrict: 'A', link: link }; } ]); angular.module('timeTracker.account') .controller('RenameTeamController', [ '$modalInstance', 'teamService', 'logger', 'teamName', 'teamId', function($modalInstance, teamService, logger, teamName, teamId) { var renameTeam = this; renameTeam.currentName = teamName; renameTeam.newTeamName = teamName; renameTeam.isLoading = false; renameTeam.message = ''; renameTeam.nameIsValid = function() { return renameTeam.newTeamName && renameTeam.newTeamName.length > 0; }; renameTeam.ok = function() { if (renameTeam.currentName === renameTeam.newTeamName) { $modalInstance.close(); return; } renameTeam.isLoading = true; teamService.renameTeam(teamId, renameTeam.newTeamName) .then(function () { logger.success('The team was renamed successfully.'); $modalInstance.close(); }, function (err) { if (err.data && err.data.message) { renameTeam.message = err.data.message; } else { renameTeam.message = "The team could not be renamed: unknown error."; } }) .finally(function () { renameTeam.isLoading = false; }); }; renameTeam.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.account') .directive('ttRenameTeam', [ '$uibModal', function($uibModal) { function link(scope, element, attributes) { element.bind('click', function () { $uibModal.open({ animation: true, templateUrl: 'app/account/teams/rename-team.html', controller: 'RenameTeamController as renameTeam', size: 'lg', resolve: { teamName: function() { return attributes.teamName; }, teamId: function () { return attributes.teamId; } } }); }); } return { restrict: 'A', link: link }; } ]); angular.module('timeTracker.account') .service('teamService', [ '$http', '$rootScope', 'user', 'userService', function ($http, $rootScope, user, userService) { var onTeamMembersChangedEventName = 'onTeamMembersChanged'; var onTeamsChangedEventName = 'onTeamsChanged'; var addTeamMembers = function (teamId, emails) { var addMembersDto = { teamId: teamId, emails: emails }; return $http.post('api/team/add-members', addMembersDto) .then(function (response) { $rootScope.$emit(onTeamMembersChangedEventName); return response; }); }; // event that will be fired when team members are added var onTeamMembersChanged = function (callback) { return $rootScope.$on(onTeamMembersChangedEventName, callback); }; var teamsChanged = function () { $rootScope.$emit(onTeamsChangedEventName); }; // event that is fired when teams are added or removed var onTeamsChanged = function (callback) { return $rootScope.$on(onTeamsChangedEventName, callback); }; var teamToEdit = null; var getTeamToEdit = function () { return teamToEdit; }; var setTeamToEdit = function (team) { teamToEdit = team; }; var getTeamsToEdit = function () { return $http.get('api/team'); }; var loadTeam = function (teamId) { var config = { params: { teamId: teamId } }; return $http.get('api/team', config); }; var fireTeamsChangedEvent = function () { $rootScope.$emit(onTeamsChangedEventName); }; var addTeam = function (team) { console.debug('add Team: ', team); if (!user.teams) { user.teams = []; } user.teams.push(team); user.numberOfActiveTeams++; fireTeamsChangedEvent(); }; var createTeam = function (teamName) { var team = { name: teamName }; return $http.post('api/user/add-team', team) .then(function (response) { addTeam(response.data); userService.setCurrentTeam(response.data); return response; }); }; var updateTeamNamesOfCurrentUser = function (updatedTeam) { var index, team, teamId = parseInt(updatedTeam.teamId); if (user.teams && user.teams.length >= 1) { for (index = 0; index < user.teams.length; ++index) { team = user.teams[index]; if (team.id === teamId) { team.name = updatedTeam.newName; } } } // the current team might be another instance and not in the teams array if (user.currentTeam && user.currentTeam.id === teamId) { user.currentTeam.name = updatedTeam.newName; } }; var renameTeam = function (teamId, newName) { var teamAndName = { teamId: teamId, newName: newName }; return $http.put('api/team', teamAndName) .then(function (response) { updateTeamNamesOfCurrentUser(teamAndName); fireTeamsChangedEvent(); return response; }); }; var removeTeam = function (teamId) { return $http.delete('api/team/' + teamId); }; var makeAdmin = function (teamId, email) { var teamAndEmail = { teamId: teamId, email: email }; return $http.put('api/team/make-admin', teamAndEmail); }; var makeMember = function (teamId, email) { var teamAndEmail = { teamId: teamId, email: email }; return $http.put('api/team/make-member', teamAndEmail); }; var resendInvitation = function (teamId, email) { var teamAndEmail = { teamId: teamId, email: email }; return $http.put('api/team/resend-invitation', teamAndEmail); }; var deactivateMember = function (teamId, email) { var teamAndEmail = { teamId: teamId, email: email }; return $http.put('api/team/deactivate-member', teamAndEmail); }; var reactivateMember = function (teamId, email) { var teamAndEmail = { teamId: teamId, email: email }; return $http.put('api/team/reactivate-member', teamAndEmail); }; return { addTeamMembers: addTeamMembers, onTeamMembersChanged: onTeamMembersChanged, onTeamsChanged: onTeamsChanged, teamsChanged: teamsChanged, getTeamToEdit: getTeamToEdit, setTeamToEdit: setTeamToEdit, loadTeam: loadTeam, getTeamsToEdit: getTeamsToEdit, createTeam: createTeam, renameTeam: renameTeam, removeTeam: removeTeam, makeAdmin: makeAdmin, makeMember: makeMember, resendInvitation: resendInvitation, deactivateMember: deactivateMember, reactivateMember: reactivateMember }; } ]); angular.module('timeTracker.account') .service('userService', [ '$q', '$http', 'logger', 'user', '$rootScope', '$filter', 'localStorageService', '$location', 'eventService', function ($q, $http, logger, user, $rootScope, $filter, localStorageService, $location, eventService) { 'use strict'; const onTeamChangedEventName = 'onTeamChanged', onUserChangedEventName = 'onUserChanged'; let retryOnDisconnect = false; const setNumberOfActiveTeams = function () { user.numberOfActiveTeams = 0; let i = 0; const len = user.teams.length; for (; i < len; i++) { if (user.teams[i].userIsActive) { user.numberOfActiveTeams++; } } }; const setCurrentTeamOfUser = function (teamName) { const teams = user.teams; let foundTeam = false; for (let i = 0; i < teams.length; i++) { if (teams[i].name === teamName) { user.currentTeam = teams[i]; foundTeam = true; break; } } return foundTeam; }; const setTeamInUrl = function () { $location.search('team', user.currentTeam.name); }; const setCurrentTeamByUrl = function () { const teamQuery = $location.search().team; let updateQuery = true; if (teamQuery === user.currentTeam.name) { return; } if (teamQuery != null) { updateQuery = !setCurrentTeamOfUser(teamQuery); } if (updateQuery === true) { setTeamInUrl(); } }; function updateIfNecessary(windowBlurTimeStamp) { // if (!windowBlurTimeStamp) { // return; // } // const now = Date.now(); // const diffMilli = now - windowBlurTimeStamp; // const minutes = Math.floor(diffMilli / 1000 / 60 / 60 / 60); // console.debug('window has been idle for (ms)', diffMilli); // if (minutes < 0) { // return; // } // $http.get('api/user/last-updated-time') // .then((result) => { // const lastUpdatedElapsedMilli = result.data; // console.debug('updateIfNecessary: user has updated milliseconds ago', lastUpdatedElapsedMilli); // const windowBlurElapsedMilli = Date.now() - windowBlurTimeStamp; // const difference = windowBlurElapsedMilli - lastUpdatedElapsedMilli; // if (difference > 0) { // console.debug('updateIfNecessary: page needs refresh'); // eventService.pageNeedsRefresh(); // } // console.debug('updateIfNecessary: update if difference > 0. difference:', difference); // }); } return { refreshUser: function () { $http.get('api/user') .then(function (response) { angular.extend(user, response.data); setNumberOfActiveTeams(); user.name = $filter('userName')(user); $rootScope.$emit(onUserChangedEventName); }, function (response) { const logData = { response: response, authData: localStorageService.get('authorizationData') }; logger.error('We could not load your user data.', logData, response.status); }); }, processTeamUrl: function () { if (user.currentTeam != null) { setCurrentTeamByUrl(); } else { this.onUserChanged(setCurrentTeamByUrl); } }, resetUser: function () { for (let key in user) { if (user.hasOwnProperty(key)) { user[key] = null; } } $rootScope.$emit(onUserChangedEventName); }, setCurrentTeam: function (team) { $http.put('api/user/current-team', team) .then(function (response) { if (!response.data) { return; } user.currentTeam = response.data; setTeamInUrl(); $rootScope.$emit(onTeamChangedEventName); }, function (response) { logger.error('Current team could not be changed.', response, response.status + '!'); }); }, onCurrentTeamChanged: function (callback) { return $rootScope.$on(onTeamChangedEventName, callback); }, onUserChanged: function (callback) { return $rootScope.$on(onUserChangedEventName, callback); }, changeNames: function (names) { const userinfo = { FirstName: names.firstName, LastName: names.lastName }; return $http.put('api/user/names', userinfo) .then(function () { user.firstName = names.firstName; user.lastName = names.lastName; user.name = $filter('userName')(user); }); }, getEmailByUserId: function (userId) { const config = { params: { userId: userId } }; return $http.get('api/user/email', config); }, verifyUserIsLoggedIn: function (windowBlurTimeStamp, forceRetry) { if (retryOnDisconnect && !forceRetry) { return; } $http.get('api/user/login-status') .then(() => { if (retryOnDisconnect) { logger.success('Connected'); } retryOnDisconnect = false; logger.debug('User is still logged in.'); updateIfNecessary(windowBlurTimeStamp); }, (error) => { // ERR_INTERNET_DISCONNECTED -1 if (error.status === -1) { retryOnDisconnect = true; logger.warning('Connecting...'); setTimeout(() => this.verifyUserIsLoggedIn(windowBlurTimeStamp, true), 3000); } // 401 you are not logged in anymore else { retryOnDisconnect = false; logger.error('You are not logged in anymore.', error, error.status); } }); } }; } ]); angular.module('timeTracker.account').value('user', { // see UserDto for fields accessible from a 'user' instance id: null, email: null, currentTeam: null, teams: null, firstName: null, lastName: null, name: null, hasSetPassword: null, numberOfActiveTeams: null }); angular.module('timeTracker', ['logger', 'ui.router', 'angulartics', 'angulartics.google.analytics', 'LocalStorageModule', 'timeTracker.track', 'timeTracker.reports', 'timeTracker.directives', 'timeTracker.account', 'timeTracker.static', 'timeTracker.navigation', 'timeTracker.settings', 'bugsnagExceptionHandler', 'timeTracker.event', 'timeTracker.stats', 'title', 'timeTracker.autoComplete']) .constant('toastr', toastr) .config([ '$httpProvider', '$urlRouterProvider', function($httpProvider, $urlRouterProvider) { $httpProvider.interceptors.push('authInterceptorService'); $urlRouterProvider.otherwise('/'); } ]) .run([ '$rootScope', '$state', 'authService', 'logger', '$uibModalStack', 'title', '$interval', 'userService', function ($rootScope, $state, authService, logger, $uibModalStack, title, $interval, userService) { authService.fillAuthData(); $rootScope.$on('$stateChangeStart', function (event, toState) { $uibModalStack.dismissAll(); if (toState.name === 'refresh') { event.preventDefault(); authService.refreshToken() .then(function () { logger.success('Your token has been refreshed manually.'); }, function (response) { logger.error('Your token could not be refreshed manually.', response, response.status); }); } else { var accessRequires = toState.data.accessRequires, loggedIn = authService.authentication && authService.authentication.isAuth, open = function (state) { event.preventDefault(); $state.go(state); }; if (toState.title) { title.setPageDefault(toState.title); } else { title.setPageDefault(null); } title.update(); if (accessRequires === 'loggedIn' && !loggedIn) { open('login'); } else if (accessRequires === 'loggedOut' && loggedIn) { open('track'); } } }); $rootScope.$on('$stateChangeSuccess', function(event, toState) { userService.processTeamUrl(); logger.debug('changed state to: "' + toState.name + '"', { state: toState }); }); } ]); angular.module('timeTracker.autoComplete', []); angular.module('timeTracker.autoComplete') .value('autoCompleteNames', { projects: null, tags: null }); angular.module('timeTracker.autoComplete') .factory('autoCompleteService', [ 'logger', '$http', 'autoCompleteNames', function (logger, $http, autoCompleteNames) { 'use strict'; var loadAutoCompleteProjects = function (teamId) { var config = { params: { teamId: teamId } }; $http.get('api/records/auto-complete-projects', config) .then(function (response) { autoCompleteNames.projects = response.data; }, function (error) { logger.error('The auto completion for projects could not be loaded.', error, error.status); }); }; var loadAutoCompleteTags = function (teamId) { var config = { params: { teamId: teamId } }; $http.get('api/records/auto-complete-tags', config) .then(function (response) { autoCompleteNames.tags = response.data; }, function (error) { logger.error('The auto completion for tags could not be loaded.', error, error.status); }); }; return { loadAutoCompleteNames: function (teamId) { loadAutoCompleteProjects(teamId); loadAutoCompleteTags(teamId); } }; } ]); angular.module('timeTracker.constants', []) .value('editableTypeNames', { project: 'project', tags: 'tags', dateAndTime: 'date+time' }); angular.module('timeTracker.date', ['logger']); angular.module('timeTracker.date') .factory('DateParser', [ 'Dates', function(Dates) { function DateParser() {} var regexDate = /\b(\d{1,2}\.\d{1,2}\.(\d{4})?)($|\s)/gi, regexDuration = /(?:^|\s)(\d*:\d{1,2})\b/gi, matches, parts; DateParser.parseDate = function(stringValue) { // date: 23.04. or 1.2. or 13.3.2013 matches = regexDate.getMatches(stringValue); if (matches.length === 1) { parts = matches[0].value.split('.'); var year = parts[2].length === 4 ? parseInt(parts[2]) : Dates.now().getFullYear(); return Date.asDay(year, parseInt(parts[1]) - 1, parseInt(parts[0])); } return null; }; DateParser.parseDuration = function(stringValue) { // duration: 12 or 01:23 or :12 or :1 matches = regexDuration.getMatches(stringValue); if (matches.length === 1) { parts = matches[0].value.substr(0).split(':'); return (parseInt(parts[0] || '0') * 60 + parseInt(parts[1] || '0')) * 60 * 1000; } return null; }; return DateParser; } ]); angular.module('timeTracker.date') .factory('Dates', function() { 'use strict'; var thisWeekMonday = function (self) { // getDay() is based on Sunday being the first day of the week, i.e. Sunday === 0 var today = self.today(); if (today.getDay() === 0) { return today.getDate() - 6; } return today.getDate() - today.getDay() + 1; }; return { now: function() { return new Date(); }, today: function() { // a UTC based Date object with 0-valued time components var now = this.now(); return Date.asDay(now.getFullYear(), now.getMonth(), now.getDate()); }, startDate: function (periodName) { var start = this.today(), currentDate = start.getDate(), monday = thisWeekMonday(this), year = start.getFullYear(), month = start.getMonth(); switch (periodName) { case 'thisweek': start.setDate(monday); return start; case 'lastweek': start.setDate(monday - 7); return start; case 'last30days': start.setDate(currentDate - 30 + 1); return start; case 'last90days': start.setDate(currentDate - 90 + 1); return start; case 'last180days': start.setDate(currentDate - 180 + 1); return start; case 'last365days': start.setDate(currentDate - 365 + 1); return start; case 'thismonth': return Date.asDay(year, month, 1); case 'lastmonth': return Date.asDay(year, month - 1, 1); case 'thisyear': return Date.asDay(year, 0, 1); case 'lastyear': return Date.asDay(year - 1, 0, 1); case 'alltime': return null; default: throw 'Unknown period name: ' + periodName + '!'; } }, endDate: function (periodName) { var end = this.today(), monday = thisWeekMonday(this), year = end.getFullYear(), month = end.getMonth(); switch (periodName) { case 'lastweek': end.setDate(monday - 1); return end; case 'lastmonth': return Date.asDay(year, month, 0); case 'lastyear': return Date.asDay(year, 0, 0); case 'thisweek': case 'thismonth': case 'thisyear': case 'last30days': case 'last90days': case 'last180days': case 'last365days': return end; case 'alltime': return null; default: throw 'Unknown period name: ' + periodName + '!'; } } }; }); angular.module('timeTracker.directives', ['timeTracker.constants', 'timeTracker.record', 'logger']); angular.module('timeTracker.directives') .directive('ttAddTag', [ 'recordParser', function(recordParser) { function link(scope, element, attrs) { element.bind('click', function() { var rawInputElement = angular.element(attrs.inputElement); var changedValue = recordParser.addTag(scope.tag, scope.input); scope.changeInput(changedValue); rawInputElement.focus(); }); } return { restrict: 'A' /* use as an attribute */, scope: { tag: "=", input: "=", changeInput: "=" }, link: link }; } ]); angular.module('timeTracker.directives') .directive('ttAutoComplete', [ 'autoCompleteService', 'lastUsed', 'RecordStorage', 'autoCompleteNames', function (autoCompleteService, lastUsed, RecordStorage, autoCompleteNames) { 'use strict'; var regexProj = /(?:^|\s)(@[\S]+)/gi; var regexMultipleProjects = /(?:^|\s)(@[\S]+)\s/i; var regexTags = /(?:^|\s)(#[\S]+)/gi; var regexPrefix = /^(@|#)/; var regexAutoFocusFirst = /[\S]$/; var projectTagClasses = {}; var maximumItems = 8; var setProjectTagClasses = function (items, prefix) { if (items === null) return; for (var i = 0; i < items.length; i++) { var currentItem = items[i]; var name = currentItem.name; var cssClass = currentItem.cssClass; projectTagClasses[prefix + name] = cssClass; } }; var split = function (val) { return val.split(/\s+/); }; var extractLast = function (term) { return split(term).pop(); }; var removePrefix = function (term) { return term.replace(regexPrefix, ''); }; var getMatches = function (regex, term) { return regex.getMatches(term).map(function (item) { return removePrefix(item.value); }); }; var doesProjectExist = function (term) { return regexMultipleProjects.test(term); }; // https://stackoverflow.com/questions/3148195/jquery-ui-autocomplete-use-startswith var startsWithFilter = function (array, term) { var matcher = new RegExp('^' + $.ui.autocomplete.escapeRegex(term), 'i'); return $.grep(array, function (value) { return matcher.test(value.label || value.value || value); }); }; var getFilteredItems = function (names, searchTerm, prefix) { var filterFunc = searchTerm.length < 3 ? startsWithFilter : $.ui.autocomplete.filter; var filteredNames = filterFunc( names, searchTerm); return filteredNames.map(function (name) { return { label: name, value: prefix + name }; }); }; var getFilteredTags = function (names, fullTerm, searchTerm) { var matches = getMatches(regexTags, fullTerm); var remainingTagNames = names.filter(function (element) { return matches.indexOf(element) < 0; }); return getFilteredItems(remainingTagNames, searchTerm, '#'); }; var getSource = function (fullTerm, response, scope) { var lastTerm = extractLast(fullTerm); var firstChar = lastTerm[0]; var projectExists = doesProjectExist(fullTerm); var elements = []; var searchTerm = removePrefix(lastTerm); var filteredTags = []; var mixedSliceAmount; if (scope.onlyProject || scope.onlyTags) { mixedSliceAmount = maximumItems; } else { mixedSliceAmount = maximumItems / 2; } if (!scope.onlyProject) { filteredTags = getFilteredTags( autoCompleteNames.tags, fullTerm, searchTerm); } var filteredProjects = []; if (!scope.onlyTags) { filteredProjects = getFilteredItems( autoCompleteNames.projects, searchTerm, '@'); } if (firstChar === '@') { if (!projectExists) { elements = filteredProjects.slice(0, maximumItems); } } else if (firstChar === '#') { elements = filteredTags.slice(0, maximumItems); } else { if (projectExists) { elements = filteredTags.slice(0, maximumItems); } else { elements = filteredProjects .slice(0, mixedSliceAmount) .concat(filteredTags.slice(0, mixedSliceAmount)); } } response(elements); }; var renderItem = function (ul, item) { var lastTerm = extractLast(this.term); var searchTerm = removePrefix(lastTerm); var regexTerm = new RegExp(searchTerm, 'gi'); var matchedLabel = item.label.replace( regexTerm, '' + searchTerm + ''); var cssClass = projectTagClasses[item.value]; var label = '' + matchedLabel + ''; return $('
  • ') .data('item.autocomplete', item) .append(label) .appendTo(ul); }; var stayOpen; var setupAutoComplete = function (scope, element, ngModel) { $(element) // don't navigate away from the field on tab when selecting an item .on('keydown', function(event) { var self = $(this); if (self.autocomplete('instance').menu.active) { if (event.keyCode === $.ui.keyCode.TAB) { event.preventDefault(); } } else if (event.keyCode === $.ui.keyCode.ENTER) { if (event.ctrlKey) { self.blur(); } self.autocomplete('close'); self.autocomplete('option', 'disabled', true); } }) .autocomplete({ source: function (request, response) { stayOpen = false; if (autoCompleteNames.projects === null || autoCompleteNames.tags === null) { response(); } else { getSource(request.term, response, scope); } }, focus: function () { // prevent value inserted on focus return false; }, select: function (event, ui) { var terms = split(this.value); // remove the current input var firstChar = terms.pop()[0]; // add the selected item terms.push(ui.item.value); // add placeholder to get the space at the end var result = terms.join(' ') + ' '; if (ngModel !== null) { ngModel.$setViewValue(result); ngModel.$render(); } else { this.value = result; } stayOpen = true; return false; }, open: function (event, ui) { var autoFocusFirst = regexAutoFocusFirst.test(this.value); if (!autoFocusFirst) { return; } var menu = $(this).data('ui-autocomplete').menu; var items = $('li', menu.element); var item = items.eq(0); if (item) { menu.focus(null, item); } }, close: function (event, ui) { if (stayOpen) { $(element).autocomplete('search'); } }, minLength: 0 }) .click(function () { $(this).autocomplete('option', 'disabled', false); $(this).autocomplete('search'); }) .data('ui-autocomplete') ._renderItem = renderItem; }; RecordStorage.onLastUsedLoaded(function () { setProjectTagClasses( lastUsed.allTeamUsedProjects, '@'); setProjectTagClasses( lastUsed.allTeamUsedTags, '#'); }); return { restrict: 'A', require: '?ngModel', scope: { onlyProject: '=', onlyTags: '=' }, link: function (scope, element, attrs, ngModel) { setupAutoComplete(scope, element, ngModel); } }; } ]); angular.module('timeTracker.directives') .directive('ttEditable', [ 'DateParser', 'Project', 'Tag', 'formattedDateFilter', 'durationWithoutSecondsFilter', 'editableTypeNames', '$compile', function(DateParser, Project, Tag, formattedDateFilter, durationWithoutSecondsFilter, editableTypeNames, $compile) { var linker = function(scope, element, attrs, ngModel) { // the scope parameter contains the Angular scope which this directive was found in if (!ngModel) return; // do nothing if no ng-model var editableType, KEY_ENTER = 13, stringValue; element.on('click', function() { if (element.data('isEditing')) { return; } element.data('isEditing', true); if (scope.record.isMovingToTop) { scope.recordsPerDay.cancelMovingRecordToTop(scope.record.id); scope.record.cancelledMovingToTop = true; } element.hide(); stringValue = ''; var autocomplete; // the dynamically created ng-attr has not been created before (value undefined) editableType = element.data('editable'); switch (editableType) { case editableTypeNames.project: stringValue = '@' + (ngModel.$viewValue.name || ''); autocomplete = 'project'; break; case editableTypeNames.tags: // tags specific code var tags = ngModel.$viewValue; for (let i = 0; i < tags.length; i++) { stringValue += '#' + tags[i].name + ' '; } autocomplete = 'tags'; break; case editableTypeNames.dateAndTime: stringValue += formattedDateFilter(ngModel.$viewValue); stringValue += ' ' + durationWithoutSecondsFilter(scope.record.totalDuration); break; default: stringValue = ngModel.$viewValue; break; } var autocompleteDirective = ''; if (autocomplete) { autocompleteDirective = 'tt-auto-complete '; if (autocomplete === 'project') { autocompleteDirective += 'only-project'; } else { autocompleteDirective += 'only-tags'; } autocompleteDirective += '="true"'; } var textarea = $compile('')(scope); element.after(textarea) .next('textarea') .focus( /* first focus, then set the value -> cursor will jump to end of input */) .val('') .val(stringValue) .on('blur', function() { // Listen for change events to enable binding scope.$apply(read); // only update record on server if value has changed // BUG: $dirty will always be true after the first change of the value if (ngModel.$dirty) { if (scope.record) { // call into the trackCtrl/reportCtrl scope.update(scope.record, scope.recordsPerDay, editableType); } else { alert('scope.record is falsy! this has to be a bug!'); } } // delete the input $(this).remove(); // show the original element element.show(); element.closest('li').focus(); element.data('isEditing', false); if (scope.record.cancelledMovingToTop) { scope.recordsPerDay.moveRecordToTop(scope.record); } }) .on('keydown', function(ev) { if (ev.which === KEY_ENTER) { $(ev.target).blur(); } }); if (autocomplete) { textarea.triggerHandler('click'); } }); // Specify how UI should be updated ngModel.$render = function() { //element.html(ngModel.$viewValue || ''); }; // Write data to the model function read() { var stringValue = element.next('textarea').val(), valueHasChanged = false, viewValue, matches, duration; switch (editableType) { case editableTypeNames.project: matches = /(@?[\S]+)/gi.getMatches(stringValue); if (matches.length === 1) { let projectName = matches[0].value[0] === '@' ? matches[0].value.substr(1) : matches[0].value; if (projectName === '') { projectName = null; } viewValue = new Project(projectName); } else if (stringValue === '') { viewValue = new Project(null); } if (viewValue) { valueHasChanged = ngModel.$viewValue.name !== viewValue.name; } break; case editableTypeNames.tags: matches = /(#?[\S]+)/gi.getMatches(stringValue); // quick fix for multiple tags with same name // http://stackoverflow.com/questions/9229645/remove-duplicates-from-javascript-array var uniqueNames = [], uniqueElements = []; $.each(matches, function(index, element) { if ($.inArray(element.value, uniqueNames) === -1) { uniqueNames.push(element.value); uniqueElements.push(element); } }); viewValue = $.map(uniqueElements, function (match) { var tagName = match.value[0] === '#' ? match.value.substr(1) : match.value; return new Tag(tagName); }); var oldValues = $.map(ngModel.$viewValue, function(tag) { return tag.name; }), newValues = $.map(viewValue, function(tag) { return tag.name; }); valueHasChanged = !oldValues.isEquivalentTo(newValues); break; case editableTypeNames.dateAndTime: viewValue = DateParser.parseDate(stringValue); if (viewValue !== null && viewValue !== undefined) { valueHasChanged = ngModel.$viewValue.getTime() !== viewValue.getTime(); duration = DateParser.parseDuration(stringValue); if (duration !== null && duration !== scope.record.totalDuration) { scope.record.saveDurationAsInterval(duration); valueHasChanged = true; } } break; default: viewValue = stringValue; valueHasChanged = ngModel.$viewValue !== viewValue; break; } if (valueHasChanged) { // for now, updates even when trackDate or Tags do NOT change, // because viewValue is a new object instance != $viewValue ngModel.$setViewValue(viewValue); } } }; return { restrict: 'A', // only activate on element attribute require: '?ngModel', // get a hold of NgModelController link: linker }; } ]); angular.module('timeTracker.directives') .directive('ttFilterOptions', function () { return { restrict: 'A', templateUrl: 'app/components/filter-options/filter-options.html' }; }); angular.module('timeTracker.directives') .directive('ttFocusonme', function() { 'use strict'; return { restrict: 'A' /* use as an attribute */, link: function (scope, element) { element.focus(); } }; }); angular.module('timeTracker.directives') .directive('ttFocusonmeNoMobile', function() { 'use strict'; return { restrict: 'A' /* use as an attribute */, link: function (scope, element) { var ua = navigator.userAgent; // or use window.matchMedia("(max-width: 767px)").matches // https://stackoverflow.com/questions/25393865/how-do-you-detect-between-a-desktop-and-mobile-chrome-user-agent/25394023#25394023 if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(ua)) { return; } element.focus(); } }; }); angular.module('timeTracker.directives') .directive('ttKeyboardAware', ['logger', function(logger) { var linker = function(scope, element) { if (!scope.keydown) { logger.debug('No keydown function defined on the current scope/controller.'); } else { element.on('keydown', function(ev) { scope.$apply(function() { scope.keydown(ev); }); }); } }; return { restrict: 'A' /* use as an attribute */, link: linker }; }]); angular.module('timeTracker.directives') .directive('ttPercentageComparison', [ '$filter', function ($filter) { 'use strict'; var increaseClass = 'metrics-increase'; var decreaseClass = 'metrics-decrease'; var calculateComparison = function (scope, element) { var classList = element[0].classList; classList.remove(increaseClass, decreaseClass); var durations = scope.durations; var currentValue = durations.currentValue; var compareToValue = durations[scope.comparedTo]; if (scope.start == null) { return '-'; } var ratio; if (compareToValue === 0) { if (currentValue === 0) { // ± 0% ratio = 1; } else { // +100% ratio = 2; } } else { ratio = currentValue / compareToValue; } var change = $filter('numberWithoutTrailingZeros')(100 * (ratio - 1), 2); var percentage; if (change < 0) { percentage = change; classList.add(decreaseClass); } else if (change > 0) { percentage = '+' + change; classList.add(increaseClass); } else { percentage = '±0'; } return percentage + '%'; }; return { restrict: 'A', scope: { durations: '=', start: '=', comparedTo: '@' }, link: function (scope, element, attrs) { scope.$watch('durations', function () { if (scope.durations == null || scope.comparedTo == null) { return; } var text = calculateComparison(scope, element); element.html(text); }); } }; } ]); angular.module('timeTracker.directives') // check out controllers: http://www.bennadel.com/blog/2603-directive-controller-and-link-timing-in-angularjs.htm // used to set up context/vars/state before child elements are being rendered .directive('ttRunningFavicon', [ function() { var linker = function(scope) { scope.$watch( function($scope) { return $scope.isTracking(); }, function(newValue/*, oldValue*/) { $('#favicon').remove(); if (newValue) { $('head').append(''); } else { $('head').append(''); } }); }; return { restrict: 'A' /* use as an attribute */, link: linker }; } ]); angular.module('timeTracker.directives') .directive('ttSameWidthProjects', function() { var linker = function(scope, element, attrs) { scope.$watch('recordsPerDay.records.length', function(newValue, oldValue) { // only trigger on changes if (newValue !== oldValue) { updateWidth(); } }); function updateWidth() { scope.$evalAsync(function() { var maxwidth = 0, sameWidthElementSelector = attrs.ttSameWidthProjects; element.find(sameWidthElementSelector) .each(function() { var widthsArray = $(this) .find('.same-width-project') .map(function() { return $(this).innerWidth(); }) .get( /* returns a js array */); widthsArray.push(maxwidth); maxwidth = Math.max.apply(Math, widthsArray); }) .each(function() { var $this = $(this), isTracking = $this.scope().record.isTracking; $this.find('.same-width-project').innerWidth(maxwidth); // if this record is currently tracking, hook up the watcher right now so it can react to another record being started if (isTracking) { setUpWatcher($this, true); } }) .end() // hook up the watcher only when pressing the tracking button .on('mouseenter', 'button', function(ev) { var elm = element.find(sameWidthElementSelector).filter(function() { return $(this).parent().has(ev.target).length > 0; }); if (elm.length > 0) { setUpWatcher(elm); } }); }); } function setUpWatcher(elm, isTracking) { if (elm.data('watcherSetUp')) { return; } // wrapping the $watch setup in $apply/$evalAsync executes the watcher once right after setup // this makes it deterministic as to when the watcher is executed with both values the same (on the first run) elm.scope().$evalAsync(function() { elm.scope().$watch('record.isTracking', function(newValue, oldValue) { if (newValue !== oldValue) { $(this).find('.same-width-project').innerWidth(newValue ? '+=32px' : '-=32px'); } }.bind(elm)); }); if (isTracking) { elm.find('.same-width-project').innerWidth('+=32px'); } elm.data('watcherSetUp', true); } updateWidth(); }; return { restrict: 'A' /* use as an attribute */, link: linker }; }); angular.module('timeTracker.directives') .directive('ttSetProject', [ 'recordParser', function(recordParser) { function link(scope, element, attrs) { element.bind('click', function() { var rawInputElement = angular.element(attrs.inputElement); var changedValue = recordParser.setProject(scope.project, scope.input); scope.changeInput(changedValue); rawInputElement.focus(); }); } return { restrict: 'A' /* use as an attribute */, scope: { project: "=", input: "=", changeInput: "=" }, link: link }; } ]); angular.module('timeTracker.directives') .directive('ttShowdeleterecordli', function() { var linker = function(scope, element) { var deleteButton = element.closest('li').find('.record-delete'); element .on('click focus', function() { setTimeout(function() { deleteButton.addClass("show-transition"); }, 100); }) .on('blur', 'textarea', function() { // some info on blur: https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers.onblur // NOTE: In contrast to MSIE--in which almost all kinds of elements receive the blur event-- // almost all kinds of elements on Gecko browsers do NOT work with this event. setTimeout(function() { deleteButton.removeClass("show-transition"); }, 1000); }); // fallback for missing blur support: hide remove button if user clicks outside of the current record $(document).on('click', '*', function(ev) { // if not clicked on child of the current
  • hide button if (!element.closest('li').is(ev.target) && element.closest('li').find(ev.target).length === 0) { setTimeout(function() { deleteButton.removeClass("show-transition"); }, 100); } }); }; return { restrict: 'A' /* use as an attribute */, link: linker }; }); angular.module('timeTracker.directives') .directive('ttShowdeleterecordtr', function() { var linker = function(scope, element) { var deleteButton = element.closest('tr').find('.record-delete-in-report'); element .on('click focus', function() { setTimeout(function() { deleteButton.addClass("show-transition"); }, 100); }) .on('blur', 'textarea', function() { // some info on blur: https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers.onblur // NOTE: In contrast to MSIE--in which almost all kinds of elements receive the blur event-- // almost all kinds of elements on Gecko browsers do NOT work with this event. setTimeout(function() { deleteButton.removeClass("show-transition"); }, 1000); }); // fallback for missing blur support: hide remove button if user clicks outside of the current record $(document).on('click', '*', function(ev) { // if not clicked on child of the current hide button if (!element.closest('tr').is(ev.target) && element.closest('tr').find(ev.target).length === 0) { setTimeout(function() { deleteButton.removeClass("show-transition"); }, 100); } }); }; return { restrict: 'A' /* use as an attribute */, link: linker }; }); angular.module('timeTracker.event', ['ui.router']); angular.module('timeTracker.event') .service('eventService', [ '$q', '$http', 'logger', 'user', '$rootScope', function($q, $http, logger, user, $rootScope) { var refreshPageEvent = 'pageNeedsRefresh'; return { pageNeedsRefresh: function () { $rootScope.$emit(refreshPageEvent); }, onPageNeedsRefresh: function (callback) { return $rootScope.$on(refreshPageEvent, callback); } }; } ]); angular .module('bugsnagExceptionHandler', []) .factory('$exceptionHandler', function() { return function (exception, cause) { if (window.Bugsnag) { window.Bugsnag.notifyException(exception, { diagnostics: { cause: cause } }); } }; }); /* * A Regex helper function to retrieve all matches at once * incl. the indexes at which each match starts and ends. */ RegExp.prototype.getMatches = function(str) { var matches = [], result; do { result = this.exec(str); if (result) { matches.push({ value: result[1], index: result.index, lastIndex: this.lastIndex }); } } while (result); return matches; }; /* * Cut out a part of a string, analogous to Array.splice(). */ String.prototype.splice = function(startIndex, endIndex) { return this.slice(0, startIndex) + this.slice(endIndex); }; /* * Return the number of properties on the current Object. * * Do not add things to Object.prototype because it can * break enumerations in various JS libraries. * Source: http://stackoverflow.com/q/5223/177710 */ Object.size = function(obj) { var size = 0, key; for (key in obj) { if (obj.hasOwnProperty(key)) size++; } return size; }; /* * Imitate a Date only type by instantiating a Date object in UTC * with all time based parts set to 0. */ Date.asDay = function(year, month, day) { return new Date(Date.UTC(year, month, day)); }; /* * Return the last moment of the day of the current Date object (23:59:59.999) */ Date.endOfDay = function(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999); }; Array.prototype.isEquivalentTo = function(other) { if (!other) return false; if (this.length !== other.length) return false; for (var i = 0; i < this.length; i++) { if (this[i] !== other[i]) return false; } return true; }; Array.prototype.getSelected = function () { return this.filter(function (element) { return element.selected; }); }; Array.prototype.getSelectedOrAll = function () { var selectedElements = this.getSelected(); if (selectedElements.length === 0) { return this; } return selectedElements; }; Array.prototype.getSelectedIds = function () { return $.map(this.getSelected(), function (element) { return element.id; }); }; Array.prototype.getSelectedDuplicateIds = function () { var result = []; for (var i = 0; i < this.length; i++) { if (this[i].selected) { var ids = this[i].ids; for (var j = 0; j < ids.length; j++) { result.push(ids[j]); } } } return result; }; Array.prototype.getSingleSelectedParam = function (param) { var selectedElements = this.getSelected(); if (selectedElements.length === 1) { return selectedElements[0][param]; } return null; }; Array.prototype.selectSingleElement = function (element, callback, parameter) { parameter = parameter || 'name'; for (var i = 0, len = this.length; i < len; i++) { if (this[i][parameter] === element[parameter]) { if (this[i].selected) { return; } this[i].selected = true; } else { this[i].selected = false; } } this.allSelected = false; if (callback) { callback(this.indexOf(element)); } }; Array.prototype.selectIfSingle = function () { if (this.length === 1) { this[0].selected = true; } }; Array.prototype.deselectAll = function () { this.forEach(function (element) { element.selected = false; }); this.allSelected = true; }; angular.module('timeTracker.filters', ['timeTracker.date']); angular.module('timeTracker.filters') .filter('dayName', function() { 'use strict'; var daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; return function(date) { return daysOfWeek[date.getDay()]; }; }); angular.module('timeTracker.filters') .filter('durationWithoutSeconds', function () { 'use strict'; var pad = function (value) { return ('0' + value).slice(-2); }; return function (ms) { var x = ms / 60000; var minutes = Math.round(x % 60); var hours = Math.floor(x / 60); if (minutes === 60) { minutes = 0; hours = hours + 1; } return hours + ':' + pad(minutes); }; }) .filter('durationWithSeconds', function () { 'use strict'; var pad = function (value) { return ('0' + value).slice(-2); }; return function (ms) { var x = Math.floor(ms / 1000); var seconds = x % 60; x = Math.floor(x / 60); var minutes = x % 60; var hours = Math.floor(x / 60); return hours + ':' + pad(minutes) + ':' + pad(seconds); }; }) .filter('durationDecimal', function () { 'use strict'; return function (ms) { return (Math.round(ms / 36000) / 100).toFixed(2).replace('.', ','); }; }) .filter('durationTitle', ['Dates', function (Dates) { 'use strict'; return function (record) { var title = null; if (record.intervals.length > 0) { var last = record.intervals[record.intervals.length - 1]; var first = record.intervals[0]; if (last.end) { var pad = function (value) { return ('0' + value).slice(-2); }, start = first.start.getHours() + ':' + pad(first.start.getMinutes()), end = last.end.getHours() + ':' + pad(last.end.getMinutes()), pause = new Date(Dates.now() - last.end), days = pause.getUTCDate() - 1 /* because day is 1-based in Date */, hours = pause.getUTCHours(), minutes = pause.getUTCMinutes(); title = '[' + start + ' - ' + end + '] '; title += days > 0 ? days + 'd ' + hours + 'h ' + minutes + 'm' : hours > 0 ? hours + 'h ' + minutes + 'm' : minutes + ' minutes'; title += ' ago'; } } return title; }; } ]) .filter('durationHours', function () { return function (ms) { return Math.round(ms / (60 * 60 * 1000)); }; }); angular.module('timeTracker.filters') .filter('formattedDate', function () { 'use strict'; var pad = function (value) { return ('0' + value).slice(-2); }; return function (date) { if (!date) { return; } return pad(date.getDate()) + '.' + pad(date.getMonth() + 1) + '.' + date.getFullYear(); }; }) .filter('formattedDateEn', function () { 'use strict'; return function (date) { if (!date) { return; } return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear(); }; }) .filter('localTime', function () { 'use strict'; return function (ms) { if (!ms) { return '-'; } var now = new Date(); return d3.timeFormat('%H:%M')(now.setUTCHours(0, 0, 0, ms)); }; }) .filter('currentDate', function () { 'use strict'; return function () { return new Date(); }; }); angular.module('timeTracker.filters') .filter('frequencySum', function () { return function (frequency) { var text; switch (frequency) { case 'day': text = 'daily sums'; break; case 'week': text = 'weekly sums'; break; case 'month': text = 'monthly sums'; break; default: text = 'sums per ' + frequency; break; } return text; }; }); angular.module('timeTracker.filters') .filter('numberWithoutTrailingZeros', function () { 'use strict'; return function (input, decimalPlaces) { if (input == null) { return '-'; } return Number(input.toFixed(decimalPlaces)); }; }); angular.module('timeTracker.filters') .filter('projectNamesWithAt', function() { 'use strict'; return function(projects) { if (projects.length === 0) { return '(all projects)'; } var formattedProjectNames = ''; projects.forEach(function(project) { formattedProjectNames += ' @' + project.name; }); return formattedProjectNames.trim(); }; }); angular.module('timeTracker.filters') .filter('tagNamesWithHash', function() { 'use strict'; return function(tags) { if (tags.length === 0) { return '(all tags)'; } var formattedTagNames = ''; tags.forEach(function(tag) { formattedTagNames += ' #' + tag.name; }); return formattedTagNames.trim(); }; }); angular.module('timeTracker.filters') .filter('teamNames', [ function () { 'use strict'; return function (teams) { if (teams.length === 0) { return '(all teams)'; } var formattedTeamNames = []; teams.forEach(function(team) { formattedTeamNames.push(team.name); }); return formattedTeamNames.join(', '); }; } ]); angular.module('timeTracker.filters') .filter('userNames', [ function () { 'use strict'; return function (users) { if (users.length === 0) { return '(all members)'; } var formattedUserNames = []; users.forEach(function(user) { formattedUserNames.push(user.name); }); return formattedUserNames.join(', '); }; } ]) .filter('userName', [ function () { 'use strict'; return function (user) { return (user.firstName + ' ' + user.lastName).trim(); }; } ]); // the logger uses toastr: // https://github.com/CodeSeven/toastr angular.module('logger', []) .factory('logger', [ '$log', 'toastr', function($log, toastr) { 'use strict'; var logger = { showToasts: true, // add here the logger functions that should show up as toasts: 'error|warning|success|info|debug' showToastsOnLevels: 'error|warning|success|info', error: error, info: info, success: success, warning: warning, debug: debug, errorHidden: errorHidden, // straight to console and bypass toastr log: $log.log }; function error(message, data, title) { if (showToastrFor('error')) { toastr.error(message, title); } $log.error('Error: ' + message, data); errorHidden(message, data, title); } function errorHidden(message, data, title) { // https://github.com/exceptionless/Exceptionless.JavaScript var client = window.exceptionless.ExceptionlessClient.default; client.createLog('logger', message + " (" + title + ")", 'Error') .setProperty('data', data) .submit(); } function info(message, data, title) { if (showToastrFor('info')) { toastr.info(message, title); } $log.info('Info: ' + message, data); } function success(message, data, title) { if (showToastrFor('success')) { toastr.success(message, title); } $log.info('Success: ' + message, data); } function warning(message, data, title) { if (showToastrFor('warning')) { toastr.warning(message, title); } $log.warn('Warning: ' + message, data); } function debug(message, data, title) { if (showToastrFor('debug')) { if (data) { message = message + ' ' + JSON.stringify(data); } toastr.info(message, title); } $log.debug('Debug: ' + message, data); } function showToastrFor(loggerType) { return logger.showToasts && logger.showToastsOnLevels !== null && logger.showToastsOnLevels.indexOf(loggerType) > -1; } return logger; } ]); angular.module('timeTracker.record', ['timeTracker.date', 'logger', 'angulartics', 'angulartics.google.analytics']); angular.module('timeTracker.record') .factory('Duration', [ 'Interval', function(Interval) { function Duration(duration) { this.totalDuration = 0; this.intervals = []; if (duration) { this.init(duration); } return this; } Duration.prototype = { updateTotalDuration: function() { var totalDuration = 0; this.intervals.forEach(function(interval) { totalDuration += interval.getDuration(); }); this.totalDuration = totalDuration; }, init: function(duration) { var params = { duration: duration }; var interval = new Interval(params); this.intervals.push(interval); this.updateTotalDuration(); }, startTracking: function() { this.intervals.push(new Interval()); }, stopTracking: function() { this.intervals.forEach(function(interval) { if (interval.isOpen()) { interval.stop(); } }); this.updateTotalDuration(); } }; return Duration; } ]); angular.module('timeTracker.record') .factory('Interval', [ 'Dates', 'logger', function(Dates, logger) { 'use strict'; function Interval(params) { /// ///The start date is set automatically, if not provided. /// if (params && !params.start && (params.duration === null || params.duration === undefined) && params.end) { logger.error('Creating an Interval instance with only the end property set is not supported!'); return; } var options = angular.extend({ duration: null /* number of milliseconds */, start: null /* Date */, end: null /* Date */, isManual: false }, params); if (options.duration !== null) { options.end = options.end || Dates.now( /* yes, now */); options.start = new Date(options.end.getTime() - options.duration); options.isManual = true; } else if (!options.start) { options.start = Dates.now( /* yes, now */); options.end = null; } this.start = options.start; this.end = options.end; this.isManual = options.isManual; } Interval.prototype = { getDuration: function() { return (this.end || Dates.now( /* yes, now */)).getTime() - this.start.getTime(); }, stop: function() { if (!this.end) { this.end = Dates.now( /* yes, now */); } else { logger.warn("Calling stop() on an interval that already has an end date looks like a bug!"); } }, isOpen: function() { return !this.end; } }; return Interval; } ]); angular.module('timeTracker.record') .factory('Project', function () { 'use strict'; function Project(name) { this.name = name; this.cssClass = null; } return Project; }); angular.module('timeTracker.record') .factory('RecordFilter', [ function () { 'use strict'; return { userFilter: function (user) { return function (record) { return record.user && record.user.id === user.id; }; }, projectFilter: function (project) { return function (record) { return record.project && record.project.name === project.name; }; }, tagFilter: function (tag) { return function (record) { if (record.tags) { for (var i = 0, len = record.tags.length; i < len; i++) { var currentTag = record.tags[i]; if (currentTag && currentTag.name === tag.name) { return true; } } } return false; }; } }; } ]); angular.module('timeTracker.record') .factory('recordParser', [ 'Record', 'Project', 'Tag', 'Dates', function(Record, Project, Tag, Dates) { /// var regexProj = /(?:^|\s)(@[\S]+)/gi, regexTags = /(?:^|\s)(#[\S]+)/gi, regexDuration = /(?:^|\s)(\d*:\d{1,2})\b/gi, regexDate = /\b(\d{1,2}\.\d{1,2}\.(\d{4})?)($|\s)/gi, parts, current; return { // remove all projects in input field and add selected project to input field setProject: function(projectName, rawString) { var matches = regexProj.getMatches(rawString); for (var i = matches.length - 1; i >= 0; i--) { rawString = rawString.splice(matches[i].index, matches[i].lastIndex).trim(); } if (rawString === null) { rawString = ''; } return ("@" + projectName + ' ' + rawString).trim() + ' '; }, // add tag to input field if tag is not already present in input field addTag: function(tagName, rawString) { var matches = regexTags.getMatches(rawString); for (var i = 0; i < matches.length; i++) { if (matches[i].value.substr(1).toLowerCase() === tagName.toLowerCase()) { return rawString; } } if (rawString === null) { rawString = ''; } return (rawString.trim() + ' #' + tagName).trim() + ' '; }, parse: function(rawString) { var matches, record = new Record(), duration = null, errors = []; current = rawString || ''; // project: @projectName matches = regexProj.getMatches(current); if (matches.length === 1) { record.project = new Project(matches[0].value.substr(1)); current = current.splice(matches[0].index, matches[0].lastIndex).trim(); } else if (matches.length > 1) { errors.push('Too many projects. Please use only 1 project per record.'); } else { record.project = new Project(null); } // tags: #tag1 #tag2 #tag-3 matches = regexTags.getMatches(current); record.tags = []; var toLowerCaseName = function(tag) { return tag.name.toLowerCase(); }; for (var i = matches.length - 1; i >= 0; i--) { // only add to tags if not already included var match = matches[i].value.substr(1); var notInTags = record.tags.map(toLowerCaseName).indexOf(match.toLowerCase()) === -1; if (notInTags) { record.tags.unshift(new Tag(match)); } current = current.splice(matches[i].index, matches[i].lastIndex).trim(); } // duration: 12 or 01:23 matches = regexDuration.getMatches(current); if (matches.length === 1) { parts = matches[0].value.split(':'); duration = (parseInt(parts[0] || '0') * 60 + parseInt(parts[1])) * 60 * 1000; current = current.splice(matches[0].index, matches[0].lastIndex).trim(); } else if (matches.length > 1) { errors.push('Too many durations. Please use only 1 duration per record.'); } // date: 23.04. or 1.2. or 13.3.2013 matches = regexDate.getMatches(current); if (matches.length > 1) { errors.push('Too many dates. Please use only 1 date per record.'); } if (matches.length === 1) { // KJ: why can I can enter "@pro" but not "@pro 15.01.2015" (if date=today)? if (!duration || duration <= 0) { // TODO errors errors.push('Please provide a duration. Records with dates can only be saved in combination with a duration. '); } else { parts = matches[0].value.split('.'); var year = parts[2].length === 4 ? parseInt(parts[2]) : Dates.now().getFullYear(); record.trackDate = Date.asDay(year, parseInt(parts[1]) - 1, parseInt(parts[0])); current = current.splice(matches[0].index, matches[0].lastIndex).trim(); } } else if (matches.length === 0) { record.trackDate = Dates.today(); } // take remaining parts as description record.description = current.replace(/\s{2,}/g, ' '); if (errors.length !== 0) { return { isSuccess: false, record: null, errorMessages: errors }; } else { if (duration !== null) { record.saveDurationAsInterval(duration); } else { record.startTracking(); } return { isSuccess: true, record: record, errorMessages: errors }; } } }; } ]); angular.module('timeTracker.record') .factory('RecordStorage', [ 'Record', '$http', 'logger', '$analytics', 'lastUsed', '$rootScope', function (Record, $http, logger, $analytics, lastUsed, $rootScope) { 'use strict'; var onLastUsedLoadedEvent = 'onLastUsedLoadedEvent'; return { loadRecords: function (team, callback) { var config = { params: { teamId: team.id } }; $http.get('api/records', config) .success(function (jsonRecords) { var records = []; // transform the json data to the record models jsonRecords.forEach(function (jsonRecord) { records.push(Record.fromJson(jsonRecord)); }); if (callback) { callback(records); } }) .error(function (data, status) { logger.error('Records could not be loaded', data, 'Error loading records (' + status + ')'); }); }, add: function (record, callback) { $http.post('api/records', record) .success(function (data) { record.id = data.id; record.project.cssClass = data.project.cssClass; record.tags = data.tags; if (callback) { callback(); } $analytics.eventTrack('createRecord', { category: 'Tracking' }); }) .error(function (data, status) { logger.error('Saving new record failed', data, status); }); }, update: function (record, callback, callbackFinal) { $http.put('api/records', record) .success(function (data) { record.project.cssClass = data.project.cssClass; record.tags = data.tags; // record is not dirty any more - or update local record with data? logger.debug('Successfully updated record on server:', data); if (callback) { callback(); } }) .error(function (data, status) { logger.error('Saving record failed', data, status); }) .finally(function () { if (callbackFinal) { callbackFinal(); } }); }, remove: function (id, callback, callbackFinal) { $http.delete('api/records/' + id) .success(function () { if (callback) { callback(); } }) .error(function (data, status) { logger.error('Deleting the record failed', data, status); }) .finally(function () { if (callbackFinal) { callbackFinal(); } }); }, lastUsed: function (team) { var config = { params: { teamId: team.id } }; $http.get('api/records/last-used', config) .then(function (response) { angular.extend(lastUsed, response.data); $rootScope.$emit(onLastUsedLoadedEvent); }, function (response) { var text = 'The suggestions for the last used and the most used projects and tags could not be loaded.'; logger.error(text, response, response.status); }); }, onLastUsedLoaded: function (callback) { $rootScope.$on(onLastUsedLoadedEvent, callback); }, getDurations: function (team, callback) { var config = { params: { teamId: team.id } }; $http.get('api/records/durations', config) .success(function (durations) { callback(durations); }) .error(function (data, status) { logger.error('The duration summaries could not be loaded.', data, status); }); } }; } ]); angular.module('timeTracker.record') .factory('RecordTemplateStorage', [ 'Record', '$http', 'logger', function (Record, $http, logger) { 'use strict'; return { get: function (teamId, callback) { var config = { params: { teamId: teamId } }; $http.get('api/record-templates/get', config) .success(function (recordTemplates) { if (callback) { callback(recordTemplates); } }) .error(function (data, status) { logger.error('Record templates could not be loaded', data, status); }); }, create: function (name, data, teamId, callback, callbackFinal) { var recordTemplateDto = { name: name, data: data, teamId: teamId }; $http.post('api/record-templates', recordTemplateDto) .success(function () { if (callback) { callback(); } }) .error(function (status) { logger.error('Creating record template failed', status); }) .finally(function () { if (callbackFinal) { callbackFinal(); } }); }, delete: function (id, callback) { $http.delete('api/record-templates/' + id) .success(function () { if (callback) { callback(); } }) .error(function (data, status) { logger.error('Deleting the record template failed', data, status); }); } }; } ]); angular.module('timeTracker.record') .factory('Record', [ 'Interval', 'Dates', 'logger', function(Interval, Dates, logger) { 'use strict'; function Record() { this.project = null; this.tags = []; this.description = null; this.user = null; this.intervals = []; this.trackDate = null; this.isTracking = false; this.totalDuration = 0; this.teamId = null; this.teamName = null; } Record.prototype = { getDuration: function() { this.updateTotalDuration(); return this.totalDuration; }, saveDurationAsInterval: function(duration) { // here we set the end date of the new interval const endOfDay = Date.endOfDay(this.trackDate), now = Dates.now(); const isToday = this.trackDate.getTime() < now.getTime() && now.getTime() < endOfDay.getTime(); const end = isToday ? now : endOfDay; const interval = new Interval({duration: duration, end: end}); // reset the intervals to the new interval this.intervals = [interval]; this.updateTotalDuration(); }, startTracking: function() { if (this.isTracking === true) { logger.warning('This record is already tracking! Calling startTracking() on it seems to be a bug!', this); return; } this.isTracking = true; this.intervals.push(new Interval()); }, stopTracking: function() { this.intervals.forEach(function(interval) { if (interval.isOpen()) { interval.stop(); } }); this.isTracking = false; this.updateTotalDuration(); }, updateTotalDuration: function() { let totalDuration = 0; this.intervals.forEach(function(interval) { totalDuration += interval.getDuration(); }); this.totalDuration = totalDuration; } }; Record.fromJson = function(jsonRecord) { const intervals = []; let interval; const record = new Record(); angular.extend(record, jsonRecord); record.trackDate = new Date(jsonRecord.trackDate); jsonRecord.intervals.forEach(function(jsonInterval) { let start = null, end = null; if (jsonInterval.start) { start = new Date(jsonInterval.start); } if (jsonInterval.end) { end = new Date(jsonInterval.end); } interval = new Interval({ start: start, end: end, isManual: jsonInterval.isManual }); intervals.push(interval); }); record.intervals = intervals; record.updateTotalDuration(); return record; }; return Record; } ]); angular.module('timeTracker.record') .factory('RecordsPerDay', [ function() { function RecordsPerDay(trackDate) { this.records = []; this.totalDuration = 0; this.trackDate = trackDate; } var intervalMoveRecordToTop = {}; function getIndexOfTrackingRecord(records) { var record = records.find(function(el) { return el.isTracking; }); return records.indexOf(record); } RecordsPerDay.prototype = { updateTotalDuration: function() { var totalDuration = 0; this.records.forEach(function(record) { totalDuration += record.totalDuration; }); this.totalDuration = totalDuration; }, recordWithIdDoesExist: function (id) { var idWasFound = false; if (id) { // some(): breaks out of the loop with 'true' this.records.some(function (record) { idWasFound = record.id === id; return idWasFound; }); } return idWasFound; }, addRecord: function(record, shouldAddAtTop) { if (this.recordWithIdDoesExist(record.id)) { return; } record.updateTotalDuration(); var index = shouldAddAtTop ? 0 : 1; this.records.splice(index, 0, record); this.updateTotalDuration(); }, removeRecord: function(record) { var index = this.records.indexOf(record); if (index > -1) { this.records.splice(index, 1); this.updateTotalDuration(); } }, cancelMovingRecordToTop: function (recordId) { if (intervalMoveRecordToTop[recordId]) { clearTimeout(intervalMoveRecordToTop[recordId]); } }, moveRecordToTop: function (record) { // do not need to move record to the top if (this.records.length <= 1) { return; } record.isMovingToTop = true; var self = this; var interval = function () { var index = self.records.indexOf(record); // only if record is not already at the top if (index <= 0 || index === 1 && getIndexOfTrackingRecord(self.records) === 0) { record.isMovingToTop = false; delete intervalMoveRecordToTop[record.id]; return; } var nextIndex = index - 1; while (nextIndex > 0 && self.records[nextIndex].isMovingToTop) { nextIndex--; } self.records.splice(index, 1); self.records.splice(nextIndex, 0, record); intervalMoveRecordToTop[record.id] = setTimeout(interval, 1500); } intervalMoveRecordToTop[record.id] = setTimeout(interval, 3000); } }; return RecordsPerDay; } ]); angular.module('timeTracker.record') .factory('Tag', function() { 'use strict'; function Tag(name) { this.name = name; this.cssClass = null; } return Tag; }); angular.module('title', []) .factory('title', [ function () { 'use strict'; var defaultText = 'lemon timetracker', pageDefaultText = null, setTitle = function (text) { document.title = text; }, getTitle = function (text) { return text + ' - ' + defaultText; }; return { update: function () { var title; if (pageDefaultText) { title = pageDefaultText; } else { title = defaultText; } setTitle(title); }, set: function (text) { var title = getTitle(text); setTitle(title); }, setPageDefault: function (text) { pageDefaultText = getTitle(text); } }; } ]); angular.module('timeTracker.navigation', ['ui.router', 'logger', 'ui.bootstrap']); angular.module("timeTracker.navigation") .controller("HeaderController", ['$uibModal', function($uibModal) { var header = this; header.openFlyoutNav = function() { $uibModal.open({ animation: true, size: 'nav', // becomes class="modal-nav" templateUrl: 'app/navigation/navigation.html', controller: 'NavigationController as navigation', backdropClass: 'flyout-backdrop', windowClass: 'flyout-modal' }); // modal close() handler the angular way: chaining a promise // use the same close logic on dismiss and close //modalInstance.result.then(onClose, onClose); }; }] ); angular.module("timeTracker.navigation") .controller("MainController", ['$state', function($state) { var main = this; main.stateClass = function () { return $state.$current.name; }; }] ); angular.module("timeTracker.navigation") .controller("NavigationController", ['$modalInstance', function($modalInstance) { var navigation = this, selected = null, subselected = null, contentStack = []; navigation.select = function(item) { selected = item; }; navigation.isSelected = function(item) { return selected === item; }; navigation.subSelect = function(item) { subselected = item; }; navigation.isSubSelected = function(item) { return subselected === item; }; navigation.close = function() { // handles state reset $modalInstance.close(); }; navigation.closeSubNav = function() { selected = null; subselected = null; navigation.contentUrl = null; navigation.cssClass = null; }; navigation.contentUrl = null; navigation.titles = []; navigation.open = function(title, url, cssClass) { contentStack = [{ title: title, url: url, cssClass: cssClass }]; navigation.titles = [title]; navigation.contentUrl = url; navigation.cssClass = cssClass; }; navigation.subOpen = function(title, url) { contentStack.push({ title: title, url: url }); navigation.titles.push(title); navigation.contentUrl = url; }; navigation.back = function() { contentStack.pop(); var previous = contentStack[contentStack.length - 1]; navigation.titles.pop(); navigation.contentUrl = previous.url; }; navigation.closeContent = function() { subselected = null; navigation.contentUrl = null; navigation.cssClass = null; navigation.titles = []; }; navigation.flyoutClass = function() { var className = "flyout-level-1"; if (selected) { className = "flyout-level-2"; } if (subselected) { className = "flyout-level-3"; } return className; }; }] ); angular.module('timeTracker.navigation') .directive('ttWindowFocus', ['$window', '$log', 'authService', 'userService', function($window, $log, authService, userService) { return { link: link, restrict: 'A' }; function link(scope) { // Hook up focus-handler. const win = angular.element($window).on("focus", handleFocus).on("blur", handleBlur); let blurTimeStamp; // When the scope is destroyed, we have to make sure to teardown // the event binding so we don't get a leak. scope.$on("$destroy", handleDestroy); // I teardown the directive. function handleDestroy() { win.off("focus", handleFocus); } // I handle the focus event on the Window. function handleFocus() { $log.debug("Window focussed"); // in case we SHOULD be logged in, verify that we are by refreshing the user info if (authService.authentication.isAuth) { userService.verifyUserIsLoggedIn(blurTimeStamp); } } function handleBlur() { blurTimeStamp = Date.now(); } } }]); angular.module('timeTracker.reports', ['timeTracker.filters', 'ui.router', 'logger']) .config([ '$stateProvider', function ($stateProvider) { 'use strict'; $stateProvider.state('reports', { title: 'Reports', url: '/reports?team', reloadOnSearch: false, templateUrl: 'app/reports/reports.html', controller: 'ReportCtrl', data: { accessRequires: 'loggedIn' } }); } ]); angular.module('timeTracker.reports') .controller('EditRecordController', [ '$modalInstance', '$uibModal', 'RecordStorage', 'logger', 'record', '$filter', 'DateParser', 'Tag', 'Project', 'user', function ($modalInstance, $uibModal, RecordStorage, logger, record, $filter, DateParser, Tag, Project, user) { var editRecord = this; editRecord.date = record.trackDate ? $filter('formattedDate')(record.trackDate) : ''; editRecord.projectName = record.project.name || ''; editRecord.tags = record.tags.reduce(function (tagA, tagB) { return tagA ? tagA + " " + tagB.name : tagB.name; }, ""); editRecord.description = record.description; editRecord.time = record.totalDuration ? $filter('durationWithoutSeconds')(record.totalDuration) : ''; editRecord.isLoading = false; editRecord.message = ''; editRecord.teamName = record.teamName; editRecord.teamNames = user.teams.map(function (team) { return team.name; }); function getChangedTrackingDate() { return DateParser.parseDate(editRecord.date); } function getChangedProject() { var matches = /(@?[\S]+)/gi.getMatches(editRecord.projectName); if (matches.length === 1) { var projectName = matches[0].value[0] === '@' ? matches[0].value.substr(1) : matches[0].value; return new Project(projectName); } else if (editRecord.projectName === '') { return new Project(null); } return null; } function getChangedTags() { var matches = /(#?[\S]+)/gi.getMatches(editRecord.tags), uniqueNames = [], uniqueElements = []; $.each(matches, function (index, element) { if ($.inArray(element.value, uniqueNames) === -1) { uniqueNames.push(element.value); uniqueElements.push(element); } }); return $.map(uniqueElements, function (match) { var tagName = match.value[0] === '#' ? match.value.substr(1) : match.value; return new Tag(tagName); }); } function getChangedDuration() { return editRecord.time ? DateParser.parseDuration(editRecord.time) : 0; } editRecord.isValid = function () { var isValid = true; editRecord.message = ''; if (!getChangedTrackingDate()) { isValid = false; editRecord.message += 'The tracking date is not valid. '; } if (!getChangedProject()) { isValid = false; editRecord.message += 'You must not specify more than one project. '; } if (getChangedDuration() === null) { isValid = false; editRecord.message += 'The entered time is not valid. '; } return isValid; }; function updateRecordBeforeSaving() { record.trackDate = getChangedTrackingDate(); record.project = getChangedProject(); record.tags = getChangedTags(); record.description = editRecord.description; var teams = user.teams; var teamName = editRecord.teamName; for (var i = 0; i < teams.length; i++) { if (teams[i].name === teamName) { record.teamId = teams[i].id; record.teamName = teamName; break; } } var duration = getChangedDuration(); if (duration !== record.totalDuration) { record.saveDurationAsInterval(duration); } } editRecord.ok = function () { updateRecordBeforeSaving(); editRecord.isLoading = true; RecordStorage.update( record, function () { logger.success('The record was updated successfully.'); $modalInstance.close(); }, function () { editRecord.isLoading = false; }); }; editRecord.delete = function () { editRecord.isLoading = true; RecordStorage.remove( record.id, function () { logger.success('The record was deleted successfully.'); $modalInstance.close(true); }, function () { editRecord.isLoading = false; }); }; editRecord.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.reports') .controller('EditReportConfigurationController', [ '$modalInstance', '$uibModal', 'ReportConfigurationStorage', 'logger', 'reportConfiguration', function ($modalInstance, $uibModal, ReportConfigurationStorage, logger, reportConfiguration) { var editReportConfiguration = this; editReportConfiguration.name = ''; editReportConfiguration.isLoading = false; editReportConfiguration.isValid = function() { return (editReportConfiguration.name && editReportConfiguration.name.length > 0); }; editReportConfiguration.ok = function() { reportConfiguration.name = editReportConfiguration.name; editReportConfiguration.isLoading = true; ReportConfigurationStorage.saveReportConfiguration( reportConfiguration.toJson(), function (savedConfiguration) { logger.success('The filters were saved successfully.'); $modalInstance.close(savedConfiguration); }, function () { editReportConfiguration.isLoading = false; } ); }; editReportConfiguration.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.reports') .factory('ReportConfigurationStorage', [ 'ReportConfiguration', '$http', 'logger', function(ReportConfiguration, $http, logger) { 'use strict'; return { getReportConfigurations: function (callback) { $http.get('api/saved-reports') .success(function (jsonReportConfigurations) { var reportConfigurations = []; jsonReportConfigurations.forEach(function (savedConfiguration) { reportConfigurations.push(savedConfiguration); }); callback(reportConfigurations); }) .error(function (data, status) { logger.error('The saved reports could not be loaded.', data, status); }); }, saveReportConfiguration: function (configJson, callback, callbackFinal) { $http.post('api/saved-reports', configJson) .success(function (response) { logger.debug('Filters have been saved.'); if (callback) { callback(response); } }) .error(function (data, status) { logger.error('The filters could not be saved.', data, status); }) .finally(function () { if (callbackFinal) { callbackFinal(); } }); }, deleteReportConfiguration: function (id, callback) { $http.delete('api/saved-reports/' + id) .success(function () { if (callback) { callback(); } }) .error(function (data, status) { logger.error('Deleting the filters failed', data, status); }); } }; } ]); angular.module('timeTracker.reports') .factory('ReportConfiguration', [ 'Dates', '$filter', function (Dates, $filter) { 'use strict'; function ReportConfiguration() { this.teams = []; this.projects = []; this.tags = []; this.users = []; this.reportPeriods = [ { name: 'thisweek', displayName: 'this week', selected: true }, { name: 'lastweek', displayName: 'last week', selected: false }, { name: 'thismonth', displayName: 'this month', selected: false }, { name: 'lastmonth', displayName: 'last month', selected: false }, { name: 'thisyear', displayName: 'this year', selected: false }, { name: 'lastyear', displayName: 'last year', selected: false }, { name: 'alltime', displayName: 'all time', selected: false } ]; this.updateInterval(); this.orderProperty = 'trackDate'; this.orderReverse = false; this.datePickerStartOpened = false; this.datePickerEndOpened = false; this.dateOptions = { startingDay: 1, showWeeks: false }; } var findSingleMatch = function (collection1, collection2) { for (var i = 0; i < collection1.length; i++) { for (var j = 0; j < collection2.length; j++) { if (collection1[i] === collection2[j]) { return true; } } } return false; }; var setAllSelected = function (fullArray, selectedArray) { fullArray.allSelected = selectedArray.length === 0 || selectedArray.length === fullArray.length; }; var updateSelectionFromConfigJson = function (self, json) { if (json.reportPeriod) { self.reportPeriods.forEach(function (period) { period.selected = json.reportPeriod === period.name; }); } else { self.start = json.startDate; self.end = json.endDate; self.reportPeriods.deselectAll(); } self.updateInterval(); self.users.forEach(function (user) { user.selected = json.userIds && json.userIds.indexOf(user.id) !== -1; }); setAllSelected(self.users, json.userIds); self.tags.forEach(function (tag) { tag.selected = json.tagIds && findSingleMatch(tag.ids, json.tagIds); }); setAllSelected(self.tags, json.tagIds); self.projects.forEach(function (project) { project.selected = json.projectIds && findSingleMatch(project.ids, json.projectIds); }); setAllSelected(self.projects, json.projectIds); self.orderProperty = json.orderProperty; self.orderReverse = json.orderReverse; self.teams.forEach(function (team) { team.selected = json.teamIds && json.teamIds.indexOf(team.id) !== -1; }); setAllSelected(self.teams, json.teamIds); }; var groupFilters = function (collection) { var names = []; var result = []; for (var i = 0; i < collection.length; i++) { var name = collection[i].name; var index = names.indexOf(name); if (index > -1) { result[index].ids.push(collection[i].id); result[index].selected = result[index].selected || collection[i].selected; result[index].dropped = result[index].dropped && collection[i].dropped; } else { names.push(name); var id = collection[i].id; delete collection[i].id; collection[i].ids = [ id ]; result.push(collection[i]); } } return result; }; ReportConfiguration.setJsonInterval = function (json) { if (!json.startDate && !json.endDate && json.reportPeriod) { json.startDate = Dates.startDate(json.reportPeriod) || ''; json.endDate = Dates.endDate(json.reportPeriod); } return json; }; ReportConfiguration.prototype = { getIntervalText: function () { var text, periodName = this.reportPeriods.getSingleSelectedParam('displayName'); if (periodName) { text = periodName; } else { var dateText = $filter('formattedDate'); text = (dateText(new Date(this.start)) || 'all time start') + ' - ' + (dateText(new Date(this.end)) || 'today'); } return text; }, toJson: function() { return { name: this.name, startDate: this.start, endDate: this.end, reportPeriod: this.reportPeriods.getSingleSelectedParam('name'), projectIds: this.projects.getSelectedDuplicateIds(), tagIds: this.tags.getSelectedDuplicateIds(), userIds: this.users.getSelectedIds(), teamIds: this.teams.getSelectedIds(), orderProperty: this.orderProperty, orderReverse: this.orderReverse }; }, updateFromFilterOptions: function (filterOptions, configJson) { if (!filterOptions) { return; } var getFilterElement = function (element, dropped) { element.selected = false; element.dropped = dropped; return element; }; var teams = []; filterOptions.teams.forEach(function (team) { teams.push(getFilterElement(team, false)); }); filterOptions.teamsDropped.forEach(function (team) { teams.push(getFilterElement(team, true)); }); this.teams = teams; var users = []; filterOptions.users.forEach(function (user) { users.push(getFilterElement(user, false)); }); filterOptions.usersDropped.forEach(function (user) { users.push(getFilterElement(user, true)); }); this.users = users; var projects = []; filterOptions.projects.forEach(function (project) { projects.push(getFilterElement(project, false)); }); filterOptions.projectsDropped.forEach(function (project) { projects.push(getFilterElement(project, true)); }); this.projects = groupFilters(projects); var tags = []; filterOptions.tags.forEach(function (tag) { tags.push(getFilterElement(tag, false)); }); filterOptions.tagsDropped.forEach(function (tag) { tags.push(getFilterElement(tag, true)); }); this.tags = groupFilters(tags); updateSelectionFromConfigJson(this, configJson); }, startChanged: function () { if (this.end < this.start) { this.end = this.start; } this.reportPeriods.deselectAll(); }, endChanged: function () { if (this.end < this.start) { this.end = this.start; } this.reportPeriods.deselectAll(); }, updateInterval: function () { var selectedPeriods = this.reportPeriods.getSelected(); if (selectedPeriods && selectedPeriods.length === 1) { var activePeriod = selectedPeriods[0]; if (activePeriod) { this.start = Dates.startDate(activePeriod.name); this.end = Dates.endDate(activePeriod.name); } } }, selectPeriod: function (period, callback) { var self = this; self.datePickerStartOpened = false; self.datePickerEndOpened = false; self.reportPeriods.selectSingleElement(period, function () { self.updateInterval(); callback(); }); }, deselectFilters: function (propertyName, callback) { var array = this[propertyName]; if (array.allSelected) { return; } array.deselectAll(); if (callback) { callback(); } }, toggleFilter: function (element, propertyName, callback) { element.selected = !element.selected; var array = this[propertyName], totalAmount = array.length, selectedAmount = array.getSelected().length; array.allSelected = selectedAmount === 0 || selectedAmount === totalAmount; if (element.dropped) { var i = array.indexOf(element); array.splice(i, 1); if (selectedAmount === 0) { callback(); } } else if (totalAmount !== 1) { callback(); } }, openDatePickerStart: function () { this.datePickerStartOpened = true; }, openDatePickerEnd: function () { this.datePickerEndOpened = true; } }; return ReportConfiguration; } ]); angular.module('timeTracker.reports') .controller('ReportCtrl', [ '$scope', 'ReportStorage', 'ReportConfigurationStorage', 'RecordStorage', 'ReportConfiguration', 'user', 'userService', 'eventService', '$uibModal', 'RecordFilter', function ($scope, ReportStorage, ReportConfigurationStorage, RecordStorage, ReportConfiguration, user, userService, eventService, $uibModal, RecordFilter) { 'use strict'; $scope.records = []; $scope.projects = []; $scope.users = []; $scope.totalTime = 0; $scope.config = new ReportConfiguration(); $scope.savedReportConfigurations = []; $scope.recordsView = true; $scope.projectsUsageView = false; $scope.isLoading = false; $scope.filterLoading = false; $scope.showMembers = true; var getTotalDuration = function (records) { var duration = 0; records.forEach(function (val) { duration += val.totalDuration; }); return duration; }, calculateProjectUsage = function () { $scope.projects = []; $scope.users = []; var i = 0; $scope.config.users.getSelectedOrAll().forEach(function (user) { var filteredRecords = $scope.records.filter(RecordFilter.userFilter(user)); if (filteredRecords.length > 0) { $scope.users[i] = { name: user.name, totalDuration: getTotalDuration(filteredRecords) }; $scope.config.projects.getSelectedOrAll().forEach(function (project) { $scope.users[i][project.name] = { totalDuration: getTotalDuration(filteredRecords.filter(RecordFilter.projectFilter(project))) }; }); i++; } }); $scope.config.projects.getSelectedOrAll().forEach(function (project) { var filteredRecords = $scope.records.filter(RecordFilter.projectFilter(project)); if (filteredRecords.length > 0) { $scope.projects.push({ ids: project.ids, name: project.name, cssClass: project.cssClass, totalDuration: getTotalDuration(filteredRecords) }); } }); }, reloadReportConfigurations = function () { ReportConfigurationStorage.getReportConfigurations( function (reportConfigurations) { $scope.savedReportConfigurations = reportConfigurations; } ); }, lastCallTimeRecords, reloadRecords = function () { $scope.isLoading = true; lastCallTimeRecords = new Date(); ReportStorage.loadRecords( $scope.config.toJson(), lastCallTimeRecords, function (records, callTime) { // only update records for the last filter call when filter calls overlap if (callTime !== lastCallTimeRecords) { return; } $scope.records = records; $scope.totalTime = getTotalDuration(records); if ($scope.projectsUsageView) { calculateProjectUsage(); } }, function (callTime) { if (callTime !== lastCallTimeRecords) { return; } $scope.isLoading = false; }); }, lastCallTimeFilters, reloadFilterOptions = function (configJson) { $scope.filterLoading = true; lastCallTimeFilters = new Date(); ReportStorage.getFilterOptions( configJson, lastCallTimeFilters, function (filterOptions, callTime) { if (callTime !== lastCallTimeFilters) { return; } $scope.config.updateFromFilterOptions( filterOptions, configJson ); reloadRecords(); }, function (callTime) { if (callTime !== lastCallTimeFilters) { return; } $scope.filterLoading = false; } ); }, activeSavedReportId, reloadFiltersAndRecords = function (configJson) { if (configJson == null) { configJson = $scope.config.toJson(); } activeSavedReportId = null; reloadFilterOptions(configJson); }, reloadPage = function () { if (!user.currentTeam) { return; } var configJson = $scope.config.toJson(); configJson.teamIds = [ user.currentTeam.id ]; reloadFiltersAndRecords(configJson); reloadReportConfigurations(); }, deregisterTeamChanged = userService.onCurrentTeamChanged(reloadPage), deregisterUserChanged = userService.onUserChanged(reloadPage), deregisterPageNeedsRefresh = eventService.onPageNeedsRefresh(reloadPage); $scope.showRecords = function () { $scope.recordsView = true; $scope.projectsUsageView = false; }; $scope.showProjectsUsage = function () { calculateProjectUsage(); $scope.recordsView = false; $scope.projectsUsageView = true; }; $scope.isActiveSavedReport = function (configurationId) { return configurationId === activeSavedReportId; }; $scope.isActiveInTeam = function () { return user.currentTeam && user.currentTeam.userIsActive; }; $scope.selectSavedReportConfiguration = function (json) { if (json.id === activeSavedReportId) { return; } activeSavedReportId = json.id; reloadFilterOptions( ReportConfiguration.setJsonInterval(json) ); }; $scope.deleteSavedReportConfiguration = function (jsonReportCfg) { ReportConfigurationStorage.deleteReportConfiguration( jsonReportCfg.id, function () { $scope.savedReportConfigurations.splice( $scope.savedReportConfigurations.indexOf(jsonReportCfg), 1); } ); }; var getCallback = function (propertyName) { return propertyName === 'tags' ? reloadRecords : reloadFiltersAndRecords; }; $scope.toggleFilter = function (element, propertyName) { $scope.config .toggleFilter(element, propertyName, getCallback(propertyName)); }; $scope.deselectFilters = function (propertyName) { $scope.config.deselectFilters(propertyName, getCallback(propertyName)); }; function closePopdrop() { $('.st312.popdrop').removeClass('popdrop-open'); } $scope.selectPeriod = function (reportPeriod) { $scope.config .selectPeriod(reportPeriod, reloadFiltersAndRecords); closePopdrop(); }; $scope.update = function (record) { RecordStorage.update(record); }; $scope.createPdf = function (topBottomIndex) { if (topBottomIndex === 0) { $scope.showPdfLoadingTop = true; } else { $scope.showPdfLoadingBottom = true; } $scope.config.users.selectIfSingle(); ReportStorage.getReportPdf( $scope.config.toJson(), function () { $scope.showPdfLoadingTop = false; $scope.showPdfLoadingBottom = false; } ); }; $scope.edit = function (record) { $uibModal.open({ animation: true, templateUrl: 'app/reports/edit-record.html', controller: 'EditRecordController as editRecord', size: 'lg', resolve: { record: record } }).result.then(function (isDeleted) { if (isDeleted) { $scope.tempRecord = record; $scope.tempRecord.id = undefined; } reloadFiltersAndRecords(); }); }; $scope.undoRemove = function () { if (!$scope.tempRecord) { return; } RecordStorage.add( $scope.tempRecord, function () { reloadFiltersAndRecords(); $scope.tempRecord = null; } ); }; $scope.saveReportConfiguration = function () { $uibModal.open({ animation: true, templateUrl: 'app/reports/edit-report-configuration.html', controller: 'EditReportConfigurationController as editReportConfiguration', size: 'lg', resolve: { reportConfiguration: $scope.config } }).result.then(function (savedConfiguration) { if (savedConfiguration) { $scope.savedReportConfigurations.push(savedConfiguration); } }); }; $scope.datePickerStartChanged = function () { if (angular.isDate($scope.config.start)) { $scope.config.startChanged(); reloadFiltersAndRecords(); } }; $scope.datePickerEndChanged = function () { if (angular.isDate($scope.config.end)) { $scope.config.endChanged(); reloadFiltersAndRecords(); closePopdrop(); } }; $scope.$on('$destroy', function () { deregisterTeamChanged(); deregisterUserChanged(); deregisterPageNeedsRefresh(); }); reloadPage(); } ]); angular.module('timeTracker.reports') .factory('ReportStorage', [ 'Record', 'ReportConfiguration', '$http', 'logger', function(Record, ReportConfiguration, $http, logger) { 'use strict'; return { loadRecords: function (configJson, callTime, callback, callbackFinal) { $http.get('api/reports', { params: configJson }) .then(function (response) { var records = []; // transform the json data to the record models response.data.forEach(function(jsonRecord) { records.push(Record.fromJson(jsonRecord)); }); callback(records, callTime); }, function (error) { logger.error('The report could not be loaded.', error.data, error.status); }) .finally(function () { callbackFinal(callTime); }); }, getFilterOptions: function (configJson, callTime, callback, callbackFinal) { $http.get('api/filter-options', { params: configJson }) .then(function (response) { callback(response.data, callTime); }, function (error) { logger.error('The report filters could not be loaded.', error.data, error.status); }) .finally(function () { callbackFinal(callTime); }); }, getReportPdf: function (configJson, callbackFinal) { // give user report for download - code from http://stackoverflow.com/questions/24080018/download-file-from-a-webapi-method-using-angularjs $http.get('api/reports/pdf', { params: configJson, responseType: 'arraybuffer' }) .success(function(data, status, headers) { var octetStreamMime = 'application/octet-stream'; var success = false; // Get the headers headers = headers(); // Get the filename from the x-filename header or default to "download.bin" var filename = headers['x-filename'] || 'report.pdf'; // Determine the content type from the header or default to "application/octet-stream" var contentType = headers['content-type'] || octetStreamMime; try { // Try using msSaveBlob if supported logger.log("Trying saveBlob method ..."); var blob = new Blob([data], { type: contentType }); if (navigator.msSaveBlob) navigator.msSaveBlob(blob, filename); else { // Try using other saveBlob implementations, if available var saveBlob = navigator.webkitSaveBlob || navigator.mozSaveBlob || navigator.saveBlob; if (saveBlob === undefined) throw "Not supported"; saveBlob(blob, filename); } logger.log("saveBlob succeeded"); success = true; } catch (ex) { logger.log("saveBlob failed with the following exception:", ex); } if (!success) { // Get the blob url creator var urlCreator = window.URL || window.webkitURL || window.mozURL || window.msURL; if (urlCreator) { // Try to use a download link var link = document.createElement('a'); if ('download' in link) { // Try to simulate a click try { // Prepare a blob URL logger.log("Trying download link method with simulated click ..."); var blob2 = new Blob([data], { type: contentType }); var url = urlCreator.createObjectURL(blob2); link.setAttribute('href', url); // Set the download attribute (Supported in Chrome 14+ / Firefox 20+) link.setAttribute("download", filename); // Simulate clicking the download link var event = document.createEvent('MouseEvents'); event.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); link.dispatchEvent(event); logger.log("Download link method with simulated click succeeded"); success = true; } catch (ex) { logger.log("Download link method with simulated click failed with the following exception:"); logger.log(ex); } } if (!success) { // Fallback to window.location method try { // Prepare a blob URL // Use application/octet-stream when using window.location to force download logger.log("Trying download link method with window.location ..."); var blob3 = new Blob([data], { type: octetStreamMime }); var url2 = urlCreator.createObjectURL(blob3); window.location = url2; logger.log("Download link method with window.location succeeded"); success = true; } catch (ex) { logger.log("Download link method with window.location failed with the following exception:"); logger.log(ex); } } } } }) .error(function(data) { logger.error('The report pdf could not be loaded.', data); }) .finally(callbackFinal); } }; } ]); angular.module('timeTracker.settings', ['ui.router', 'logger', 'ui.bootstrap']); angular.module('timeTracker.settings') .controller('ManageProjectsController', [ 'projectSettingsService', 'user', '$uibModal', function(projectSettingsService, user, $uibModal) { var manageProjects = this; manageProjects.teamName = user.currentTeam.name; manageProjects.projects = []; manageProjects.getProjects = function () { projectSettingsService.getProjects(user.currentTeam) .then(function (response) { manageProjects.projects = response.data; }); }; manageProjects.editProject = function (project) { $uibModal.open({ animation: true, templateUrl: 'app/settings/project-settings.html', controller: 'ProjectSettingsController as projectSettings', size: 'lg', resolve: { project: project } }).result.then(manageProjects.getProjects); }; manageProjects.canEditProjects = false; if (user.currentTeam.userIsAdmin && user.currentTeam.userIsActive) { manageProjects.canEditProjects = true; manageProjects.getProjects(); } } ]); angular.module('timeTracker.settings') .controller('ManageTagsController', [ 'tagSettingsService', 'user', '$uibModal', function (tagSettingsService, user, $uibModal) { var manageTags = this; manageTags.teamName = user.currentTeam.name; manageTags.tags = []; manageTags.updateTags = function () { tagSettingsService.getTags(user.currentTeam) .then(function (response) { manageTags.tags = response.data; }); }; manageTags.editTag = function (tag) { $uibModal.open({ animation: true, templateUrl: 'app/settings/tag-settings.html', controller: 'TagSettingsController as tagSettings', size: 'lg', resolve: { tag: tag } }).result.then(manageTags.updateTags); }; manageTags.canEditTags = false; if (user.currentTeam.userIsAdmin && user.currentTeam.userIsActive) { manageTags.canEditTags = true; manageTags.updateTags(); } } ]); angular.module('timeTracker.settings') .controller('ProjectMergeController', [ '$modalInstance', 'oldProjectName', 'newProjectName', function ($modalInstance, oldProjectName, newProjectName) { var projectMerge = this; projectMerge.oldProjectName = oldProjectName; projectMerge.newProjectName = newProjectName; projectMerge.ok = function () { $modalInstance.close(); }; projectMerge.cancel = function () { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.settings') .controller('ProjectSettingsController', [ '$modalInstance', '$uibModal', 'projectSettingsService', 'user', 'logger', 'project', 'eventService', function ($modalInstance, $uibModal, projectSettingsService, user, logger, project, eventService) { var projectSettings = this; projectSettings.oldProjectName = project.name; projectSettings.newProjectName = project.name; projectSettings.oldProjectCssClass = project.cssClass; projectSettings.newProjectCssClass = project.cssClass; projectSettings.isLoading = false; projectSettings.message = ''; projectSettings.messageValid = ''; projectSettings.classRange = []; projectSettings.classRange.push(projectSettings.oldProjectCssClass); for (var i = 1; i < 107; i++) { var classToAdd = "project" + i; if (classToAdd !== projectSettings.oldProjectCssClass) { projectSettings.classRange.push(classToAdd); } } projectSettings.nameIsValid = function () { var isValid = true; projectSettings.messageValid = ''; if (!projectSettings.newProjectName) { projectSettings.messageValid = 'You must specify a name.'; isValid = false; } return isValid; }; projectSettings.isCurrentClass = function (classToCompare) { return projectSettings.newProjectCssClass === classToCompare; }; projectSettings.setCurrentClass = function (newClass) { projectSettings.newProjectCssClass = newClass; }; projectSettings.changeProject = function () { projectSettings.isLoading = true; projectSettingsService.changeProject(user.currentTeam, project.id, projectSettings.newProjectName, projectSettings.newProjectCssClass) .then(function () { eventService.pageNeedsRefresh(); logger.success('The project was saved successfully.'); $modalInstance.close(); }, function (err) { if (err.data && err.data.message) { projectSettings.message = err.data.message; } else { projectSettings.message = "The tag could not be saved. An unknown error occurred."; } logger.error(projectSettings.message); }) .finally(function () { projectSettings.isLoading = false; }); }; projectSettings.ok = function () { projectSettingsService.otherProjectWithNameExists(user.currentTeam, project.id, projectSettings.newProjectName).then(function(response) { if (response.data.projectExists) { var modalInstance = $uibModal.open({ animation: true, size: 'lg', templateUrl: 'app/settings/project-merge-info.html', controller: 'ProjectMergeController as projectMerge', resolve: { oldProjectName: function() { return projectSettings.oldProjectName; }, newProjectName: function() { return projectSettings.newProjectName; } } }); modalInstance.result.then(function () { projectSettings.changeProject() ; }, function () {}); } else { projectSettings.changeProject(); } }); }; projectSettings.cancel = function () { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.settings') .service('projectSettingsService', [ '$http', function ($http) { 'use strict'; var getProjects = function (team) { var config = { params: { teamId: team.id } }; return $http.get('api/projects', config); }; var otherProjectWithNameExists = function (team, projectId, projectName) { var config = { params: { teamId: team.id, projectId: projectId, projectName: projectName } }; return $http.get('api/projects/project-exists', config); }; var changeProject = function (team, projectId, newName, newClass) { var project = { teamId: team.id, projectId: projectId, newName: newName, newClass: newClass }; return $http.put('api/projects', project); }; return { getProjects: getProjects, otherProjectWithNameExists: otherProjectWithNameExists, changeProject: changeProject }; } ]); angular.module('timeTracker.settings') .controller('TagMergeController', [ '$modalInstance', 'oldTagName', 'newTagName', function ($modalInstance, oldTagName, newTagName) { var tagMerge = this; tagMerge.oldTagName = oldTagName; tagMerge.newTagName = newTagName; tagMerge.ok = function () { $modalInstance.close(); }; tagMerge.cancel = function () { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.settings') .controller('TagSettingsController', [ '$modalInstance', '$uibModal', 'tagSettingsService', 'user', 'logger', 'tag', 'eventService', function($modalInstance, $uibModal, tagSettingsService, user, logger, tag, eventService) { var tagSettings = this; tagSettings.oldTagName = tag.name; tagSettings.newTagName = tag.name; tagSettings.oldTagCssClass = tag.cssClass; tagSettings.newTagCssClass = tag.cssClass; tagSettings.isLoading = false; tagSettings.message = ''; tagSettings.messageValid = ''; tagSettings.classRange = []; tagSettings.classRange.push(tagSettings.oldTagCssClass); for (var i = 1; i < 440; i++) { var classToAdd = "tag" + i; if (classToAdd !== tagSettings.oldTagCssClass) { tagSettings.classRange.push(classToAdd); } } tagSettings.nameIsValid = function () { var isValid = true; tagSettings.messageValid = ''; if (!tagSettings.newTagName) { tagSettings.messageValid = 'You must specify a name.'; isValid = false; } return isValid; }; tagSettings.isCurrentClass = function (classToCompare) { return tagSettings.newTagCssClass === classToCompare; }; tagSettings.setCurrentClass = function (newClass) { tagSettings.newTagCssClass = newClass; }; tagSettings.changeTag = function () { tagSettings.isLoading = true; tagSettingsService.changeTag(user.currentTeam, tag.id, tagSettings.newTagName, tagSettings.newTagCssClass) .then(function () { eventService.pageNeedsRefresh(); logger.success('The tag was saved successfully.'); $modalInstance.close(); }, function (err) { if (err.data && err.data.message) { tagSettings.message = err.data.message; } else { tagSettings.message = "The tag could not be saved. An unknown error occurred."; } logger.error(tagSettings.message); }) .finally(function () { tagSettings.isLoading = false; }); }; tagSettings.ok = function () { tagSettingsService.otherTagWithNameExists(user.currentTeam, tag.id, tagSettings.newTagName).then(function(response) { if (response.data.tagExists) { var modalInstance = $uibModal.open({ animation: true, size: 'lg', templateUrl: 'app/settings/tag-merge-info.html', controller: 'TagMergeController as tagMerge', resolve: { oldTagName: function() { return tagSettings.oldTagName; }, newTagName: function() { return tagSettings.newTagName; } } }); modalInstance.result.then(function () { tagSettings.changeTag(); }, function () {}); } else { tagSettings.changeTag(); } }); }; tagSettings.cancel = function () { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.settings') .service('tagSettingsService', [ '$http', function ($http) { 'use strict'; var getTags = function (team) { var config = { params: { teamId: team.id } }; return $http.get('api/tags', config); }; var otherTagWithNameExists = function (team, tagId, tagName) { var config = { params: { teamId: team.id, tagId: tagId, tagName: tagName } }; return $http.get('api/tags/tag-exists', config); }; var changeTag = function (team, tagId, newName, newClass) { var changeTagDto = { teamId: team.id, tagId: tagId, newName: newName, newClass: newClass }; return $http.put('api/tags', changeTagDto); }; return { getTags: getTags, otherTagWithNameExists: otherTagWithNameExists, changeTag: changeTag }; } ]); angular.module('timeTracker.static', ['ui.router']) .config([ '$stateProvider', function ($stateProvider) { 'use strict'; $stateProvider.state('about-us', { title: 'About us', url: '/about-us?team', reloadOnSearch: false, templateUrl: 'app/static/about-us.html', data: { requireLogin: false } }); $stateProvider.state('contact', { title: 'Contact', url: '/contact?team', reloadOnSearch: false, templateUrl: 'app/static/contact.html', data: { requireLogin: false } }); $stateProvider.state('help', { title: 'Help', url: '/help?team', reloadOnSearch: false, templateUrl: 'app/static/help.html', data: { requireLogin: false } }); } ]); angular.module('timeTracker.stats', ['timeTracker.filters', 'logger']) .config([ '$stateProvider', function ($stateProvider) { 'user strict'; $stateProvider.state('stats', { title: 'Stats', url: '/stats?team', reloadOnSearch: false, templateUrl: 'app/stats/stats.html', controller: 'StatsCtrl', data: { accessRequires: 'loggedIn' } }); } ]); angular.module('timeTracker.stats') .directive('barChart', [ '$filter', 'chartService', '$timeout', function ($filter, chartService, $timeout) { 'use strict'; var maxBarThickness = 100, margin = { top: 70, right: 30, bottom: 100, left: 60 }, innerHeight = 180, fullHeight = innerHeight + margin.top + margin.bottom; return { restrict: 'E', scope: { data: '=', key: '@', value: '@', keyTracked: '@', keyTotal: '@', chartCss: '@', fixedWidth: '=', chartColored: '=', showBarInfo: '=', labelDate: '=', keyIsDate: '=', parentId: '@', bars: '@', frequency: '@' }, link: function (scope, element) { if (!scope.key || !scope.value || !scope.parentId) { return; } var svg = d3.select(element[0]) .append('svg') .attr('width', '100%') .attr('height', fullHeight), container = svg.append('g') .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); var redraw = function (newData) { // clear the elements inside of the directive container.selectAll('*').remove(); // if 'data' is undefined, exit if (!newData) { return; } if (scope.bars && scope.frequency) { var frequency = newData[scope.frequency]; newData = newData[scope.bars]; newData.frequency = frequency; } var fullWidth = chartService.getWidth(scope.parentId), innerWidth = fullWidth - margin.left - margin.right, chart = { x: {}, y: { max: d3.max(newData, function (d) { return d[scope.value]; }) } }; // x axis chart.x.scale = d3 .scaleBand() .domain(newData.map(function (d) { return d[scope.key]; })) .range([0, innerWidth]) .padding(0.3); chart.x.bandwidth = chart.x.scale.bandwidth(); chart.x.axis = d3.axisBottom(chart.x.scale); if (scope.keyIsDate) { var tickFormat; if (newData.frequency === 'month') { tickFormat = d3.utcFormat('%B %Y'); } else { tickFormat = d3.utcFormat('%d.%m.%Y'); } chart.x.axis.tickFormat(function (d) { return tickFormat(new Date(d)); }); } // y axis chart.y.ticks = chartService.getTickDurations(chart.y.max, 4); chart.y.scale = d3 .scaleLinear() .domain([0, chart.y.ticks.last]) .range([innerHeight, 0]); chart.y.axis = d3 .axisLeft(chart.y.scale) .tickValues(chart.y.ticks) .tickFormat(function (ms) { return $filter('durationHours')(ms) + 'h'; }); // y axis container .append('g') .call(chart.y.axis) .attr('class', 'y-axis'); // x axis container .append('g') .call(chart.x.axis) .attr('class', 'x-axis') .attr('transform', 'translate(0,' + innerHeight + ')') // rotate x-Axis text .selectAll('text') .attr('dx', '-1em') .attr('dy', '0') .attr('transform', 'rotate(-45)'); var barContainer = container .selectAll('.bar-container') .data(newData) .enter() .append('g') .attr('class', 'bar-container') .each(function (d) { d.x = chart.x.scale(d[scope.key]); d.y = chart.y.scale(d[scope.value]); }) .attr('transform', function (d) { return 'translate('+ d.x +','+ d.y +')'; }), filteredBarContainer = barContainer .filter(function (d) { return d[scope.value] > 0; }), bar = filteredBarContainer .append('rect') .attr('class', function (d, i) { var string = 'bar project-fill'; if (scope.chartCss) { string += ' ' + d[scope.chartCss]; } if (scope.chartColored) { string += ' project' + (i % 104 + 1); } return string; }) .attr('width', Math.min(chart.x.bandwidth, maxBarThickness)) .attr('height', function (d) { return innerHeight - d.y; }), doubleOffset = chart.x.bandwidth - maxBarThickness; if (doubleOffset > 0) { bar.attr('x', doubleOffset / 2); } if (scope.showBarInfo) { var barInfo = container .selectAll('bar-info-container') .data(newData) .enter() .filter(function (d) { return d[scope.value] > 0; }) .append('g') .attr('class', 'bar-info-container') .attr('transform', function (d) { return 'translate(' + chart.x.scale(d[scope.key]) + ',' + chart.y.scale(d[scope.value]) + ')'; }) .append('text') .attr('x', chart.x.bandwidth / 2) .attr('class', 'bar-info') .attr('dy', '-1em'), filter; if (newData.length <= 20) { filter = $filter('durationWithoutSeconds'); } else { filter = $filter('durationHours'); } barInfo.text(function (d) { return filter(d[scope.value]); }); if (chart.x.bandwidth < 60) { barInfo .attr('transform', 'rotate(-45,'+ chart.x.bandwidth / 2 +',0)') .attr('dx', '0.5em'); } else { barInfo.attr('text-anchor', 'middle'); } } if (scope.keyTracked && scope.keyTotal) { barContainer .append('title') .text(function (d) { return 'Tracked on ' + d[scope.keyTracked] + ' of ' + d[scope.keyTotal] + ' days'; }); } }; scope.$watch('data', function (newData) { // width is not correct on load $timeout(function () { redraw(newData); }, 50); }); var drawingPromise; window.addEventListener('resize', function () { $timeout.cancel(drawingPromise); drawingPromise = $timeout(function () { redraw(scope.data); }, 100); }); } }; } ]); angular.module('timeTracker.stats') .factory('chartService', [ '$filter', function ($filter) { 'use strict'; var hourInMilli = 60 * 60 * 1000; var convertHoursInMilli = function (hoursArray) { for (var i = 0; i < hoursArray.length; i++) { hoursArray[i] *= hourInMilli; } }; return { getWidth: function (parentId) { var parentElem = document.getElementById(parentId), parentStyle = window.getComputedStyle(parentElem); return parseFloat(parentStyle.getPropertyValue('width')); }, getTickDurations: function (maxDuration, maxTicks) { if (maxDuration == null) { return { last: null }; } // get smallest interval var interval, ceiling, hours = [ 1, 2, 5 ], hourIndex = 0, multiplier = 0; convertHoursInMilli(hours); while (true) { interval = hours[hourIndex] * Math.pow(10, multiplier); ceiling = interval * maxTicks; if (maxDuration <= ceiling) { break; } if (hourIndex < 2) { hourIndex++; } else { hourIndex = 0; multiplier++; } } var ticks = [], tick = interval, maxTick = maxDuration + interval; for (; tick < maxTick; tick += interval) { ticks.push(tick); } ticks.last = ticks[ticks.length - 1] || 0; return ticks; } }; } ]); angular.module('timeTracker.stats') .directive('graphChart', [ '$filter', 'chartService', '$timeout', function ($filter, chartService, $timeout) { 'use strict'; var margin = { top: 40, right: 30, bottom: 100, left: 60 }, innerHeight = 150, totalHeight = innerHeight + margin.top + margin.bottom; return { restrict: 'E', scope: { data: '=', key: '@', value: '@', graphName: '@', graphData: '@', graphCss: '@', legend: '=', parentId: '@' }, link: function (scope, element) { if (!scope.key || !scope.value || !scope.parentId) { return; } var svg = d3.select(element[0]) .append('svg') .attr('width', '100%') .attr('height', totalHeight), container = svg.append('g') .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'), data, getClass = function (d, i, defaultName, classes) { var string = defaultName + ' ' + defaultName + i; if (scope.graphCss) { string += ' ' + d[scope.graphCss]; } if (classes) { string += ' ' + classes; } return string.trim(); }, getClassFillArea = function (d, i) { return getClass(d, i, 'fill-area', 'project-fill'); }, getClassGraph = function (d, i) { return getClass(d, i, 'graph', 'project-stroke'); }, getClassDot = function (d, i) { return getClass(d, i, 'dot', 'project-fill'); }, setPoints = function (d) { var points = ''; var pointsArray = []; for (var i = 0, len = d[graphData].length; i < len; i++) { var current = d[graphData][i]; current.dateObj = new Date(current[scope.key]); var x = data.x.scale(current.dateObj), y = data.y.scale(current[scope.value]); pointsArray.push({ x: x, y: y }); points += x + ',' + y + ' '; } d.points = points.trim(); d.pointsArray = pointsArray; }, graphData = scope.graphData || 'graphData', redraw = function (newData) { // clear the elements inside of the directive container.selectAll('*').remove(); // if 'data' is undefined, exit if (!newData) { return; } if (!scope.graphData) { // single graph newData = [{ graphData: newData }]; } var totalWidth = chartService.getWidth(scope.parentId), innerWidth = totalWidth - margin.left - margin.right; data = { x: { min: new Date(d3.min(newData, function (d) { return d3.min(d[graphData], function (x) { return x[scope.key]; }); })), max: new Date(d3.max(newData, function (d) { return d3.max(d[graphData], function (x) { return x[scope.key]; }); })) }, y: { max: d3.max(newData, function (d) { return d3.max(d[graphData], function (x) { return x[scope.value]; }); }) } }; data.x.days = Math.round((data.x.max - data.x.min) / (1000*60*60*24)) || 1; // x axis data.x.scale = d3 .scaleUtc() .domain([data.x.min, data.x.max]) .range([0, innerWidth]); data.x.axis = d3 .axisBottom(data.x.scale) .tickValues(data.x.scale.ticks(Math.min(5, data.x.days))) .tickFormat($filter('formattedDate')); // y axis data.y.ticks = chartService.getTickDurations(data.y.max, 4); data.y.scale = d3 .scaleLinear() .domain([0, data.y.ticks.last]) .range([innerHeight, 0]); data.y.axis = d3 .axisLeft(data.y.scale) .tickValues(data.y.ticks) .tickFormat(function (ms) { return $filter('durationHours')(ms) + 'h'; }); var xAxis = container.append('g') .attr('class', 'x-axis') .call(data.x.axis) .attr('transform', 'translate(0,' + innerHeight + ')'); xAxis.selectAll('text') .attr('dx', '-1em') .attr('dy', '0') .attr('transform', 'rotate(-45)'); container.append('g') .attr('class', 'y-axis') .call(data.y.axis); var graphContainer = container .selectAll('.graph-container') .data(newData) .enter() .append('g') .attr('class', 'graph-container') .each(function (d, i) { setPoints(d); var self = d3.select(this); if (d.pointsArray.length === 1) { self.attr('transform', 'translate(' + (innerWidth / 2) + ', 0)'); self.append('circle') .attr('class', getClassDot(d, i)) .attr('cx', d.pointsArray[0].x) .attr('cy', d.pointsArray[0].y) .attr('r', 4); xAxis.select('.tick') .attr('transform', 'translate(' + (0.5 + innerWidth / 2) + ', 0)'); } else if (d.pointsArray.length > 1) { // graph self.append('polyline') .attr('class', getClassGraph(d, i)) .attr('points', d.points); // fill area self.append('polygon') .attr('class', getClassFillArea(d, i)) .attr('points', function () { var firstX = d.pointsArray[0].x, lastX = d.pointsArray[d.pointsArray.length - 1].x; return firstX +','+ innerHeight +' ' +d.points +' '+ lastX +','+ innerHeight; }); } var circleContainer = container .append('g') .attr('class', 'point-container'), circleClass = 'graph-point'; if (scope.graphCss) { circleClass += ' project-stroke ' + d[scope.graphCss]; } for (var j = 0, len = d.pointsArray.length; j < len; j++) { var circle = circleContainer .append('g'); circle .append('circle') .attr('cx', d.pointsArray[j].x) .attr('cy', d.pointsArray[j].y) .attr('r', 10) .attr('class', circleClass); var hoverText = ''; if (scope.graphName) { hoverText += d[scope.graphName] + ': '; } hoverText += $filter('formattedDate')(d[graphData][j].dateObj) + ' - ' + $filter('durationWithoutSeconds')(d[graphData][j][scope.value]) + 'h'; circle .append('title') .text(hoverText); } }); }; scope.$watch('data', function (newData) { // width is not correct on load $timeout(function () { redraw(newData); }, 50); }); var drawingPromise; window.addEventListener('resize', function () { $timeout.cancel(drawingPromise); drawingPromise = $timeout(function () { redraw(scope.data); }, 100); }); } }; } ]); angular.module('timeTracker.stats') .directive('pieChart', [ '$filter', 'chartService', '$timeout', function ($filter, chartService, $timeout) { 'use strict'; var margin = { top: 0, right: 30, bottom: 30, left: 0 }, totalHeight = 300, innerHeight = totalHeight - margin.top - margin.bottom; return { restrict: 'E', scope: { data: '=', key: '@', value: '@', chartCss: '@', chartColored: '=', parentId: '@' }, link: function (scope, element) { if (!scope.key || !scope.value || !scope.parentId) { return; } // set up initial svg object var svg = d3.select(element[0]) .append('svg') .attr('width', '100%') .attr('height', totalHeight), container = svg.append('g'), redraw = function (newData) { // clear the elements inside of the directive container.selectAll('*').remove(); // if 'data' is undefined, exit if (!newData) { return; } var totalWidth = chartService.getWidth(scope.parentId), innerWidth = totalWidth - margin.left - margin.right, radius = Math.min(innerWidth, innerHeight) / 2, lastIndex = newData.length - 1, totalSum = d3.sum(newData, function (d) { return d[scope.value]; }); while (lastIndex > 0 && newData[lastIndex][scope.value] / totalSum < 0.02) { lastIndex--; } var otherIndex = lastIndex + 1; if (lastIndex < newData.length - 2) { var restData = newData.slice(otherIndex); newData = newData.slice(0, otherIndex); newData[otherIndex] = {}; newData[otherIndex][scope.key] = 'other'; newData[otherIndex][scope.value] = d3.sum(restData, function (d) { return d[scope.value]; }); newData[otherIndex].other = true; } container.attr('transform', 'translate(' + (margin.left + radius) + ',' + (margin.top + radius) + ')'); var pie = d3 .pie() .sort(null) .value(function (d) { return d[scope.value]; }), arc = d3 .arc() .outerRadius(radius) .innerRadius(0), labelArc = d3 .arc() .outerRadius(radius - 30) .innerRadius(radius - 30), sector = container .selectAll('.arc') .data(pie(newData)) .enter() .append('g') .attr('class', 'arc') .each(function (d) { d.data.percentage = $filter('numberWithoutTrailingZeros')(d.data[scope.value] / totalSum * 100, 1) + '%'; d.data.formattedDuration = $filter('durationWithoutSeconds')(d.data[scope.value]) + 'h'; }); sector.append('path') .attr('class', function (d, i) { var cssClass = 'arc-path project-fill'; if (d.data.other) { cssClass += ' other'; } else { if (scope.chartCss) { cssClass += ' ' + d.data[scope.chartCss]; } if (scope.chartColored) { cssClass += ' project' + (i % 104 + 1); } } return cssClass; }) .attr('d', arc); sector.append('title') .text(function (d) { return d.data[scope.key] + ': ' + d.data.percentage + ' (' + d.data.formattedDuration + ')'; }); if (radius > 100) { sector.filter(function (d) { return d.data[scope.value] / totalSum > 0.06; }) .append('text') .attr('class', 'arc-text') .attr('transform', function (d) { return 'translate(' + labelArc.centroid(d) + ')'; }) .text(function (d) { return d.data.percentage; }); } }; scope.$watch('data', function (newData) { // width is not correct on load $timeout(function () { redraw(newData); }, 50); }); var drawingPromise; window.addEventListener('resize', function () { $timeout.cancel(drawingPromise); drawingPromise = $timeout(function () { redraw(scope.data); }, 100); }); } }; } ]); angular.module('timeTracker.stats') .controller('StatsCtrl', [ '$scope', 'statsService', 'user', 'eventService', 'userService', 'ReportConfiguration', function ($scope, statsService, user, eventService, userService, ReportConfiguration) { 'use strict'; $scope.config = new ReportConfiguration(); $scope.config.reportPeriods = [ { name: 'last30days', displayName: 'last 30 days', selected: true }, { name: 'last90days', displayName: 'last 90 days', selected: false }, { name: 'last180days', displayName: 'last 180 days', selected: false }, { name: 'last365days', displayName: 'last 365 days', selected: false }, { name: 'thismonth', displayName: 'this month', selected: false }, { name: 'lastmonth', displayName: 'last month', selected: false }, { name: 'thisyear', displayName: 'this year', selected: false }, { name: 'lastyear', displayName: 'last year', selected: false }, { name: 'alltime', displayName: 'all time', selected: false } ]; $scope.config.updateInterval(); $scope.tabs = [ { name: 'MyStats', displayName: 'My stats', selected: true }, { name: 'TeamStats', displayName: 'Team stats', selected: false } ]; var tabIndex; var initLoadingView = function () { $scope.showNoStatsMessage = false; $scope.showLoadingStatsMessage = true; $scope.showStats = false; }, initProjectsHistory = function (stats) { var projects = stats.mostUsedProjects; for (var i = 0, len = Math.min(2, projects.length); i < len; i++) { projects[i].active = true; } }, initTagHistory = function (stats) { var tags = stats.mostUsedTags; for (var i = 0, len = tags.length; i < len; i++) { if (i < 2) { tags[i].active = true; } tags[i].cssClass += ' project' + (i % 104 + 1); } }, updateActiveHistoryElements = function (legendType) { $scope.stats[legendType].activeElements = $scope.stats[legendType] .filter(function (element) { return element.active; }); }, onClickLegend = function (legendType, index, event) { event.target.classList.toggle('active'); var element = $scope.stats[legendType][index]; element.active = !element.active; }, lastCallTimeStats, reloadStats = function () { initLoadingView(); lastCallTimeStats = new Date(); statsService.getStats( $scope.config.toJson(), lastCallTimeStats, function (stats, callTime) { // on success // only update stats for the last filter call when filter calls overlap if (callTime !== lastCallTimeStats) { return; } if (stats === null) { $scope.showNoStatsMessage = true; return; } initProjectsHistory(stats); initTagHistory(stats); $scope.showStats = true; $scope.stats = stats; updateActiveHistoryElements('mostUsedProjects'); updateActiveHistoryElements('mostUsedTags'); }, function (callTime) { // on finally (success and error) if (callTime !== lastCallTimeStats) { return; } $scope.showLoadingStatsMessage = false; } ); }, lastCallTimeFilters, reloadFiltersAndStats = function (configJson) { initLoadingView(); lastCallTimeFilters = new Date(); if (configJson == null) { configJson = $scope.config.toJson(); } statsService.getFilterOptions( configJson, lastCallTimeFilters, function (filterOptions, callTime) { // on success if (callTime !== lastCallTimeFilters) { return; } $scope.config.updateFromFilterOptions( filterOptions, configJson ); reloadStats(); }, function (callTime) { // on error if (callTime !== lastCallTimeFilters) { return; } $scope.showLoadingStatsMessage = false; } ); }, selectTab = function (index) { if (!user.currentTeam) { return; } tabIndex = index; var configJson = $scope.config.toJson(); if (index === 0) { $scope.showMembers = false; configJson.userIds = [ user.id ]; configJson.teamIds = []; } else if (index === 1) { $scope.showMembers = true; configJson.teamIds = [ user.currentTeam.id ]; configJson.userIds = []; } reloadFiltersAndStats(configJson); }, reloadPage = function () { if (!user.currentTeam || !user.teams) { return; } selectTab(0); }, deregisterTeamChanged = userService.onCurrentTeamChanged(reloadPage), deregisterUserChanged = userService.onUserChanged(reloadPage), deregisterPageNeedsRefresh = eventService.onPageNeedsRefresh(reloadPage); function closePopdrop() { $('.st312.popdrop').removeClass('popdrop-open'); } var getCallback = function (propertyName) { return propertyName === 'tags' ? reloadStats : reloadFiltersAndStats; }; $scope.selectPeriod = function (period) { $scope.config .selectPeriod(period, reloadFiltersAndStats); closePopdrop(); }; $scope.toggleFilter = function (element, propertyName) { $scope.config .toggleFilter(element, propertyName, getCallback(propertyName)); }; $scope.selectTab = function (tab) { $scope.tabs .selectSingleElement(tab, selectTab); }; $scope.deselectFilters = function (propertyName) { $scope.config.deselectFilters(propertyName, getCallback(propertyName)); }; $scope.datePickerStartChanged = function () { if (angular.isDate($scope.config.start)) { $scope.config.startChanged(); reloadFiltersAndStats(); } }; $scope.datePickerEndChanged = function () { if (angular.isDate($scope.config.end)) { $scope.config.endChanged(); reloadFiltersAndStats(); closePopdrop(); } }; $scope.onClickProject = function (index, event) { onClickLegend('mostUsedProjects', index, event); updateActiveHistoryElements('mostUsedProjects'); }; $scope.onClickTag = function (index, event) { onClickLegend('mostUsedTags', index, event); updateActiveHistoryElements('mostUsedTags'); }; $scope.$on('$destroy', function () { deregisterTeamChanged(); deregisterUserChanged(); deregisterPageNeedsRefresh(); }); reloadPage(); } ]); angular.module('timeTracker.stats') .service('statsService', [ '$http', 'logger', function ($http, logger) { 'use strict'; return { getStats: function (configJson, callTime, callback, callbackFinal) { var config = { params: configJson }; $http.get('api/stats', config) .then(function (response) { callback(response.data, callTime); }, function (error) { logger.error('Stats could not be loaded.', error.data, error.status); }) .finally(function () { callbackFinal(callTime); }); }, getFilterOptions: function (configJson, callTime, onSuccessCallback, onErrorCallback) { var config = { params: configJson }; $http.get('api/filter-options', config) .then(function (response) { onSuccessCallback(response.data, callTime); }, function (error) { logger.error('The stats filters could not be loaded.', error.data, error.status); onErrorCallback(callTime); }); } }; } ]); angular.module('timeTracker.track', ['ui.router', 'timeTracker.date', 'timeTracker.filters', 'timeTracker.constants', 'logger', 'timeTracker.record', 'timeTracker.account']) .config([ '$stateProvider', function($stateProvider) { 'use strict'; $stateProvider.state('track', { title: 'Track', url: '/?team', reloadOnSearch: false, templateUrl: 'app/track/track.html', controller: 'TrackCtrl', data: { accessRequires: 'loggedIn' } }); } ]); angular.module('timeTracker.track') .controller('CreateRecordTemplateController', [ '$modalInstance', '$uibModal', 'RecordTemplateStorage', 'logger', 'record', '$filter', 'DateParser', function ($modalInstance, $uibModal, RecordTemplateStorage, logger, record, $filter, DateParser) { var newTemplate = this; newTemplate.projectName = record.project.name || ''; newTemplate.tags = record.tags.reduce(function (tagA, tagB) { return tagA ? tagA + " " + tagB.name : tagB.name; }, ""); newTemplate.name = newTemplate.tags === '' ? newTemplate.projectName : newTemplate.tags; newTemplate.description = record.description; newTemplate.time = record.totalDuration ? $filter('durationWithoutSeconds')(record.totalDuration) : ''; newTemplate.message = ''; function getProject() { var matches = /(@?[\S]+)/gi.getMatches(newTemplate.projectName); if (matches.length === 1) { var projectName = matches[0].value[0] === '@' ? matches[0].value.substr(1) : matches[0].value; return '@' + projectName; } else if (newTemplate.projectName === '') { return ''; } return null; } function getTags() { var matches = /(#?[\S]+)/gi.getMatches(newTemplate.tags), uniqueNames = [], uniqueTags = ''; $.each(matches, function (index, element) { if ($.inArray(element.value, uniqueNames) === -1) { uniqueNames.push(element.value); var tagName = element.value[0] === '#' ? element.value.substr(1) : element.value; uniqueTags += ' #' + tagName; } }); return uniqueTags; } function getDuration() { return newTemplate.time ? DateParser.parseDuration(newTemplate.time) : 0; } newTemplate.isValid = function () { var isValid = true; newTemplate.message = ''; if (getProject() === null) { isValid = false; newTemplate.message += 'You must not specify more than one project. '; } if (getDuration() === null) { isValid = false; newTemplate.message += 'The entered time is not valid. '; } return isValid; }; newTemplate.ok = function () { var data = getProject() + ' ' + getTags() + ' ' + newTemplate.description + ' ' + newTemplate.time; newTemplate.isLoading = true; RecordTemplateStorage.create( newTemplate.name, data, record.teamId, function () { logger.success('The record template was created successfully.'); $modalInstance.close(); }, function () { newTemplate.isLoading = false; }); }; newTemplate.cancel = function() { $modalInstance.dismiss('cancel'); }; } ]); angular.module('timeTracker.track') .controller('TrackCtrl', [ '$scope', '$interval', 'RecordStorage', 'RecordsPerDay', 'editableTypeNames', 'Dates', 'recordParser', 'logger', 'user', 'userService', 'eventService', 'title', '$filter', 'autoCompleteService', 'lastUsed', '$uibModal', 'RecordTemplateStorage', 'Duration', function ($scope, $interval, RecordStorage, RecordsPerDay, editableTypeNames, Dates, recordParser, logger, user, userService, eventService, title, $filter, autoCompleteService, lastUsed, $uibModal, RecordTemplateStorage, Duration) { 'use strict'; $scope.userIsActive = true; $scope.showTimesAcrossTeams = false; $scope.rawInput = null; $scope.rawInputIsValid = true; $scope.validationMessage = null; $scope.templates = null; $scope.records = []; $scope.recordsPerDayList = []; $scope.lastUsed = lastUsed; $scope.showingAllUsedProjectsAndTags = false; $scope.showAverageDurations = false; $scope.editableTypeNames = editableTypeNames; $scope.durations = { currentWeek: new Duration(), currentMonth: new Duration(), lastWeek: 0, lastMonth: 0, currentWeekAcrossTeams: new Duration(), currentMonthAcrossTeams: new Duration(), lastWeekAcrossTeams: 0, lastMonthAcrossTeams: 0 }; const track = function (message) { if (mixpanel) { mixpanel.track(message); } }; const addRecordToRecordsPerDay = function (record) { const date = record.trackDate, dateKey = new Date(date.getFullYear(), date.getMonth(), date.getDate()); let matchingRecordsPerDay, keepGoing = true; // angular forEach does not support break $scope.recordsPerDayList.forEach(function (recordsPerDay) { if (keepGoing === true && recordsPerDay.trackDate.getTime() === dateKey.getTime( /* ms since 1970 */)) { matchingRecordsPerDay = recordsPerDay; keepGoing = false; } }); if (!matchingRecordsPerDay) { matchingRecordsPerDay = new RecordsPerDay(dateKey); $scope.recordsPerDayList.push(matchingRecordsPerDay); } const shouldAddAtTop = !$scope.isTracking() || record.isTracking; matchingRecordsPerDay.addRecord(record, shouldAddAtTop); return matchingRecordsPerDay; }; const loadAutoCompleteNames = function () { autoCompleteService.loadAutoCompleteNames(user.currentTeam.id); }; const loadTemplates = function () { RecordTemplateStorage.get(user.currentTeam.id, function (templates) { $scope.templates = templates; }); }; const updateLastUsed = function () { if (!user.currentTeam || !$scope.userIsActive) { return; } loadAutoCompleteNames(); loadTemplates(); RecordStorage.lastUsed(user.currentTeam); }; let trackingPromise; const stopTitleTimer = function () { $interval.cancel(trackingPromise); trackingPromise = null; title.update(); }; const setIsTracking = function (value) { user.currentTeam.userIsTracking = value; }; function startWeekMonthDurations() { $scope.durations.currentWeek.startTracking(); $scope.durations.currentMonth.startTracking(); $scope.durations.currentWeekAcrossTeams.startTracking(); $scope.durations.currentMonthAcrossTeams.startTracking(); } function updateWeekMonthDurations() { // if user continues record from last week, calculations will be wrong (edge case) $scope.durations.currentWeek.updateTotalDuration(); $scope.durations.currentMonth.updateTotalDuration(); $scope.durations.currentWeekAcrossTeams.updateTotalDuration(); $scope.durations.currentMonthAcrossTeams.updateTotalDuration(); } function stopWeekMonthDurations() { $scope.durations.currentWeek.stopTracking(); $scope.durations.currentMonth.stopTracking(); $scope.durations.currentWeekAcrossTeams.stopTracking(); $scope.durations.currentMonthAcrossTeams.stopTracking(); } const setTrackingPromise = function (record, recordsPerDay) { const interval = 1000; setIsTracking(true); startWeekMonthDurations(); trackingPromise = $interval(function () { record.updateTotalDuration(); recordsPerDay.updateTotalDuration(); updateWeekMonthDurations(); $scope.durations.currentWeekAverage = $scope.durations.currentWeekAcrossTeams.totalDuration / $scope.durations.currentWeekDayCount; $scope.durations.currentMonthAverage = $scope.durations.currentMonthAcrossTeams.totalDuration / $scope.durations.currentMonthDayCount; title.set($filter('durationWithSeconds')(record.totalDuration)); }, interval); }; // stops both the record and the trackingPromise const stopTracking = function (record) { if (record && record.isTracking) { record.stopTracking(); resetRunningTrackers(); } }; function resetRunningTrackers() { stopWeekMonthDurations(); setIsTracking(false); stopTitleTimer(); } let recordsLoadedAtLeastOnce = false; const loadRecords = function () { if (!$scope.userIsActive) { return; } RecordStorage.loadRecords(user.currentTeam, function (records) { $scope.recordsPerDayList = []; records.forEach(function (record) { const recordsPerDay = addRecordToRecordsPerDay(record); if (record.isTracking) { setTrackingPromise(record, recordsPerDay); } }); recordsLoadedAtLeastOnce = true; $scope.records = records; if (trackingPromise === null) { setIsTracking(false); } }); }; const loadDurations = function () { if (!user.currentTeam || !$scope.userIsActive) { return; } RecordStorage.getDurations(user.currentTeam, function (durations) { $scope.durations = durations; $scope.durations.currentWeek = new Duration(durations.currentWeek); $scope.durations.currentMonth = new Duration(durations.currentMonth); $scope.durations.currentWeekAcrossTeams = new Duration(durations.currentWeekAcrossTeams); $scope.durations.currentMonthAcrossTeams = new Duration(durations.currentMonthAcrossTeams); if ($scope.isTracking()) { startWeekMonthDurations(); } const dur = $scope.durations; const timesAreEqual = dur.currentWeek.totalDuration === dur.currentWeekAcrossTeams.totalDuration && dur.lastWeek === dur.lastWeekAcrossTeams && dur.currentMonth.totalDuration === dur.currentMonthAcrossTeams.totalDuration && dur.lastMonth === dur.lastMonthAcrossTeams; $scope.showTimesAcrossTeams = !timesAreEqual; }); }; const reloadPage = function () { if (!user.currentTeam) { return; } $scope.userIsActive = user.currentTeam.userIsActive; resetRunningTrackers(); loadRecords(); loadDurations(); updateLastUsed(); if (user.email === 'anton.telle@gmail.com' || user.email === 'anton@teamaton.com') { const nextTodo = localStorage.getItem("nextTodo"); if (!!nextTodo) { setTimeout(() => { confirm(nextTodo); }, 1000); } } }; const addRecord = function (record, forceTracking) { record.teamId = user.currentTeam.id; const recordsPerDay = addRecordToRecordsPerDay(record); if (forceTracking && !record.isTracking) { record.startTracking(); } if (record.isTracking) { $scope.start(record, recordsPerDay, true /* already started and stored */); } $scope.records.unshift(record); RecordStorage.add(record, function () { updateLastUsed(); loadDurations(); }); }; const deregisterTeamChanged = userService.onCurrentTeamChanged(reloadPage); const deregisterUserChanged = userService.onUserChanged(reloadPage); const deregisterPageNeedsRefresh = eventService.onPageNeedsRefresh(reloadPage); // reload durations for last/current week/month at start of week and month $interval(function () { const now = Dates.now(); if ((now.getDay() === 1 /* new week */ || now.getDate() === 1 /* new month */) && now.getHours() === 0 && now.getMinutes() === 0) { loadDurations(); } }, 60 * 1000 /* every minute */); // is used for stopping running trackers $scope.isTracking = function () { return !!trackingPromise; }; $scope.hideNoRecordsMessage = function () { // do not show message if the records have not been loaded yet return !recordsLoadedAtLeastOnce || $scope.records && $scope.records.length > 0; }; $scope.$watch('rawInput', function() { if ($scope.rawInput !== null) { const parseResult = recordParser.parse($scope.rawInput); $scope.rawInputIsValid = parseResult.isSuccess && user.currentTeam; if (!user.currentTeam) { $scope.validationMessage = 'The record cannot be saved. No team available.'; } else if (!parseResult.isSuccess) { $scope.validationMessage = parseResult.errorMessages.join(' '); } } }); $scope.setRawInput = function(input) { $scope.rawInput = input; $scope.$apply(); $('#record-input').autocomplete('option', 'disabled', true); }; $scope.enableAutocomplete = function () { $('#record-input').autocomplete('option', 'disabled', false); }; $scope.save = function (opts) { track('record saved'); const parseResult = recordParser.parse($scope.rawInput), record = parseResult.record, isValid = parseResult.isSuccess; if (isValid) { addRecord(record, opts && opts.forceTracking); $scope.rawInput = null; } else { logger.error('Should not be saveable! Record is not valid!'); logger.debug('rawInput could not be parsed', $scope.rawInput); } }; $scope.useTemplate = function (data) { $scope.rawInput = data; $scope.save(); }; $scope.removeTemplate = function(template) { const confirmDelete = confirm('Do you really want to remove template "' + template.name + '"?'); if (!confirmDelete) { return; } const id = template.id; RecordTemplateStorage.delete(id, function () { $scope.templates = $scope.templates.filter(t => t.id !== id); }); } $scope.start = function (record, recordsPerDay, recordAlreadyStartedAndStored) { track('tracker started'); $scope.records.forEach(function(existingRecord) { if (existingRecord.isTracking === true) { stopTracking(existingRecord); RecordStorage.update(existingRecord); } }); if (recordAlreadyStartedAndStored !== true) { record.startTracking(); RecordStorage.update(record); recordsPerDay.moveRecordToTop(record); } setTrackingPromise(record, recordsPerDay); }; $scope.stop = function (record, recordsPerDay) { track('tracker stopped'); let nextTodo; if (user.email === 'anton.telle@gmail.com' || user.email === 'anton@teamaton.com') { nextTodo = prompt("What do you want to work on after the break?"); } stopTracking(record); record.updateTotalDuration(); recordsPerDay.updateTotalDuration(); RecordStorage.update(record); if (!!nextTodo) { setTimeout(() => { confirm(nextTodo); }, 1000); localStorage.setItem("nextTodo", nextTodo); } }; $scope.remove = function (record, recordsPerDay) { track('record removed'); stopTracking(record); const i = $scope.records.indexOf(record), j = $scope.recordsPerDayList.indexOf(recordsPerDay); if (i > -1) { $scope.records.splice(i, 1); } recordsPerDay.removeRecord(record); if (recordsPerDay.records.length === 0 && j > -1) { // delete this day, too, if it has no records $scope.recordsPerDayList.splice(j, 1); } RecordStorage.remove(record.id, function () { $scope.tempRecord = record; $scope.tempRecord.id = undefined; loadDurations(); updateLastUsed(); }); }; $scope.undoRemove = function () { if (!$scope.tempRecord) { return; } addRecord($scope.tempRecord); $scope.tempRecord = null; }; $scope.createRecordTemplate = function (record) { $uibModal.open({ animation: true, templateUrl: 'app/track/create-record-template.html', controller: 'CreateRecordTemplateController as createRecordTemplate', size: 'lg', resolve: { record: record } }).result.then(function () { loadTemplates(); }); }; $scope.update = function(record, recordsPerDay, editableType) { RecordStorage.update(record, function () { if (editableType === editableTypeNames.dateAndTime) { loadDurations(); } else if (editableType === editableTypeNames.project || editableType === editableTypeNames.tags) { updateLastUsed(); } }); if (editableType === editableTypeNames.dateAndTime) { stopTracking(record); const dateKey = new Date(record.trackDate.getFullYear(), record.trackDate.getMonth(), record.trackDate.getDate()); if (recordsPerDay.trackDate.getTime() !== dateKey.getTime()) { recordsPerDay.removeRecord(record); const j = $scope.recordsPerDayList.indexOf(recordsPerDay); if (recordsPerDay.records.length === 0 && j > -1) { // delete this day, too, if it has no records $scope.recordsPerDayList.splice(j, 1); } addRecordToRecordsPerDay(record); } recordsPerDay.updateTotalDuration(); } }; $scope.keydown = function(event) { if (event.ctrlKey && event.which === 13 /* ENTER */) { event.stopPropagation(); event.preventDefault(); $scope.save({ forceTracking: true }); } }; $scope.$on('$destroy', function () { stopTitleTimer(); deregisterTeamChanged(); deregisterUserChanged(); deregisterPageNeedsRefresh(); }); reloadPage(); } ]); angular.module('timeTracker.track') .directive('ttTrackForm', function() { return { restrict: 'A', templateUrl: 'app/track/track-form.html' }; });