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'
};
});