const TimeTrack = { data() { return { experimental: { trackWorktime: false, boardView: false, snippetSpace: false, portalSwitcher: true }, view: 'board', theme: 'materia', themes: null, dashboardLogo: 'assets/img/logo.png', ticketSystemUrl: '', showPT: true, dontShowMinutes: false, linkTarget: '_blank', inputs: { importJson: '' }, tickets: [], archive: [], trashed: {}, worktimeTracker: { tracking: false, number: 'Worktime', trackingStarted: null, trackingStopped: null, history: [] }, selectedTracker: null, searchQuery: '', portal: '', portals: null, importStringForPortals: '', publicDB: false, fun: false, sounds: { bad: [ 'alert', 'wilhelm', // 'wtf', // 'jesus_wtf' ], animals: [ // 'meow', // 'moo', // 'quack', // 'pika', 'transition' ] }, snippets: [], codeMirrors: [], customBookingValue: '', customDateForPastDays: '', newTaskInput: '' } }, mounted() { let vue = this; this.loadStorage(); this.fetchThemes(); moment.locale('de'); this.customDateForPastDays = moment().format(); setInterval(() => { vue.$forceUpdate(); }, 1000 * 60); vue.loadTooltips(); setInterval(() => { vue.loadTooltips(); }, 5000); if (this.experimental.snippetSpace) { this.snippets.forEach((snippet) => { vue.loadSnippet(snippet); }); setTimeout(() => { vue.updateStorage(); }, 5000); } setInterval(() => { vue.checkTimeBoxes(); }, 10000); if (this.fun && localStorage.getItem('noJokes') === null) { let jokeService = new JokeService(); setInterval(() => { jokeService.tell(); }, 1_800_000) } }, methods: { loadTooltips() { let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }); }, loadStorage() { let storedTickets = JSON.parse(localStorage.getItem('tickets')); this.tickets = storedTickets == null ? [] : storedTickets; let storedArchive = JSON.parse(localStorage.getItem('archive')); this.archive = storedArchive == null ? [] : storedArchive; let storedTicketSystemUrl = localStorage.getItem('ticketSystemUrl'); this.ticketSystemUrl = storedTicketSystemUrl == null ? '' : storedTicketSystemUrl; let storedShowPT = localStorage.getItem('showPT'); this.showPT = (storedShowPT == null || storedShowPT === 'false') ? true : storedShowPT; let storedDontShowMinutes = localStorage.getItem('dontShowMinutes'); this.dontShowMinutes = (storedDontShowMinutes == null || storedDontShowMinutes === 'false') ? false : Boolean(storedDontShowMinutes); let storedPublicDB = localStorage.getItem('publicDB'); storedPublicDB = storedPublicDB === 'true'; this.publicDB = (storedPublicDB == null || storedPublicDB === 'false') ? false : storedPublicDB; let storedTheme = localStorage.getItem('theme'); this.theme = storedTheme == null ? 'materia' : storedTheme; let storedPortal = localStorage.getItem('portal'); this.portal = storedPortal == null ? '' : storedPortal; let storedPortals = JSON.parse(localStorage.getItem('portals')); this.portals = storedPortals == null ? [] : storedPortals; let storedFun = localStorage.getItem('fun'); this.fun = storedFun == null || storedFun === 'false' ? false : storedFun; // let storedSnippets = JSON.parse(localStorage.getItem('snippets')); // this.snippets = storedSnippets == null ? [] : storedSnippets; }, updateStorage() { localStorage.setItem('tickets', JSON.stringify(this.tickets)); localStorage.setItem('archive', JSON.stringify(this.archive)); localStorage.setItem('portals', JSON.stringify(this.portals)); localStorage.setItem('ticketSystemUrl', this.ticketSystemUrl); localStorage.setItem('showPT', this.showPT); localStorage.setItem('dontShowMinutes', Boolean(this.dontShowMinutes)); localStorage.setItem('publicDB', this.publicDB); localStorage.setItem('fun', this.fun); localStorage.setItem('theme', this.theme); localStorage.setItem('portal', this.portal); this.$forceUpdate(); }, resetToDefault() { this.updateStorage(); }, createTracker() { this.tickets.push({ tracking: false, number: '#', trackingStarted: null, trackingStopped: null, history: [] }); this.updateStorage(); }, createTimeBox() { this.tickets.push({ tracking: false, number: 'Timebox ', trackingStarted: null, trackingStopped: null, isTimeBox: true, history: [] }); this.updateStorage(); }, startTimeBox(ticket, minutes) { Notification.requestPermission(); this.startTracking(ticket, false, minutes); }, startTracking(ticket, individual = false, timeBoxMinutes = null) { if (!individual) { this.stopActiveTracker(); } if (timeBoxMinutes) { ticket.timeBoxMinutes = timeBoxMinutes; } ticket.status = 'wip'; ticket.trackingStarted = moment(); ticket.tracking = true; let noNoTickets = ['#1920', '#3110', '#2492', '#2419', '#1256']; if (this.fun && noNoTickets.includes(ticket.number)) { playSound(oneOf(this.sounds.bad)); } this.$forceUpdate(); this.updateStorage(); }, stopTracking(ticket) { // console.log(ticket); ticket.trackingStopped = moment(); ticket.tracking = false; let minutesSpent = moment.duration( ticket.trackingStopped.diff(ticket.trackingStarted) ).as('minutes'); if (minutesSpent > 0) { let historyEntry = { trackingStarted: ticket.trackingStarted, trackingStopped: ticket.trackingStopped, manually: false, minutes: Math.round(minutesSpent) }; // console.log(historyEntry); if (this.experimental.trackWorktime) { if (ticket.paused) { historyEntry.pause = true; } } ticket.history.push(historyEntry); } ticket.trackingStarted = null; ticket.trackingStopped = null; this.$forceUpdate(); this.updateStorage(); }, pauseTracking(ticket) { ticket.trackingStopped = moment(); ticket.tracking = false; ticket.paused = true; this.stopTracking(ticket); }, resumeTracking(ticket) { ticket.trackingStarted = moment(); ticket.tracking = true; ticket.paused = false; let noNoTickets = ['#1920', '#3110', '#2492', '#2419', '#1256']; if (this.fun && noNoTickets.includes(ticket.number)) { playSound(oneOf(this.sounds.bad)) } this.$forceUpdate(); this.updateStorage(); }, formattedDate(date) { return moment(date).format('llll'); }, exactTimestamp(date) { return moment(date).format('LTS'); }, currentTrackingRunningFor(ticket) { return this.timeWithPostFix(Math.round(moment.duration(moment().diff(ticket.trackingStarted)).as('minutes'))); }, getTotalTime(ticket, raw = false) { let totalTime = 0; if (ticket.history.length > 0) { ticket.history.forEach(function (historyEntry) { totalTime += Math.round(historyEntry.minutes); }); } if (ticket.tracking) { totalTime += Math.round(moment.duration(moment().diff(ticket.trackingStarted)).as('minutes')); } if (raw) { return totalTime; } else { return this.timeWithPostFix(totalTime); } }, getTotalTimeToday(ticket) { let totalTime = 0; if (ticket.history.length > 0) { ticket.history.forEach(function (historyEntry) { if (moment(historyEntry.trackingStarted).format("MMM Do YY") === moment().format("MMM Do YY")) { totalTime += Math.round(historyEntry.minutes); } }); } if (ticket.tracking) { totalTime += Math.round(moment.duration(moment().diff(ticket.trackingStarted)).as('minutes')); } return this.timeWithPostFix(totalTime); }, timeWithPostFix(time) { let postFix = ' Minute'; if (time >= 480 && this.showPT) { postFix = ' PT'; time = (time / 480).toFixed(1); } else if (time >= 60 || this.dontShowMinutes) { postFix = ' Stunde'; time = (time / 60).toFixed(2); } let plural = ''; if (((time > 1 || time <= 0) || this.dontShowMinutes) && postFix !== ' PT') { plural = 'n' } return time + postFix + plural; }, stopActiveTracker() { let vue = this; vue.tickets.forEach(function (ticket) { if (ticket.tracking === true) { vue.stopTracking(ticket); } }) }, getTrackingStartTime(ticket) { return moment(ticket.trackingStarted).format('LT'); }, isTicketNumber(number) { return number.indexOf('#') >= 0; }, deleteTracker(index, archive = false) { let component = this; let message = ''; if (archive) { Object.assign(this.trashed, this.archive[index]); let name = this.archive[index].number; message = 'Tracker "' + name + '" wurde gelöscht'; this.archive.splice(index, 1); } else { Object.assign(this.trashed, this.tickets[index]); let name = this.tickets[index].number; message = 'Tracker "' + name + '" wurde gelöscht'; this.tickets.splice(index, 1); } iziToast.show({ message: message, color: 'blue', buttons: [ ['', function (instance, toast) { instance.hide({ transitionOut: 'fadeOutUp', onClosing: function(instance, toast, closedBy){ component.restoreTrashed(); } }, toast, 'buttonName'); }, true] ] }); this.updateStorage(); }, restoreTrashed() { let restoredTracker = {}; Object.assign(restoredTracker, this.trashed); this.trashed = {}; this.tickets.push(restoredTracker); this.updateStorage(); }, archiveTracker(index) { if (this.tickets[index].tracking) { this.stopActiveTracker(); } this.archive.push(this.tickets[index]); this.tickets.splice(index, 1); this.updateStorage(); }, reactivateTicket(index) { this.tickets.push(this.archive[index]); this.archive.splice(index, 1); this.updateStorage(); }, deleteHistoryEntry(ticketIndex, historyIndex) { if (ticketIndex) { this.tickets[ticketIndex].history.splice(historyIndex, 1); } else { this.selectedTracker.history.splice(historyIndex, 1); } this.updateStorage(); }, bookTimeManually(ticket, minutes) { ticket.history.push({ trackingStarted: moment(), trackingStopped: moment(), manually: true, minutes: Math.round(minutes) }); this.updateStorage(); }, importData() { let json = JSON.parse(this.inputs.importJson); if (json.trackedTickets) { this.tickets = this.extractTicketsFromLegacyJson(json.trackedTickets); this.archive = this.extractArchivedTicketsFromLegacyJson(json.trackedTickets); } else { this.tickets = json.tickets; this.archive = json.archive ?? []; } this.ticketSystemUrl = json.redmineUrl ?? json.ticketSystemUrl; this.showPT = json.showPT; this.theme = json.theme; this.updateStorage(); location.reload(); }, extractTicketsFromLegacyJson(tickets) { return this.extractTickets(tickets); }, extractArchivedTicketsFromLegacyJson(tickets) { return this.extractTickets(tickets, true); }, extractTickets(ticketCollection, forArchive = false) { let tickets = []; let archive = []; ticketCollection.forEach((ticket) => { if (ticket.archived || (ticket.active && ticket.active === false)) { archive.push(ticket); } else { tickets.push(ticket) } }); return forArchive ? archive : tickets; }, copy2Clipboard() { let copyText = document.getElementById("exportJsonInput"); copyText.select(); copyText.setSelectionRange(0, 99999); document.execCommand("copy"); }, fetchThemes() { let vue = this; axios.get( 'https://bootswatch.com/api/5.json' ).then((response) => { vue.themes = response.data.themes; }).catch((error) => { console.log(error); }); }, showHistoryForTracker(tracker) { this.selectedTracker = tracker; this.$forceUpdate(); setTimeout(() => { let historyModal = new bootstrap.Modal(document.getElementById('historyModal')); historyModal.toggle(); }, 50); }, openTasksForTracker(tracker) { this.selectedTracker = tracker; this.$forceUpdate(); setTimeout(() => { let tasksModal = new bootstrap.Modal(document.getElementById('trackerTasksModal')); tasksModal.toggle(); }, 50); }, showCustomBookingForTracker(ticket) { this.selectedTracker = ticket; this.$forceUpdate(); setTimeout(() => { let customBookingModal = new bootstrap.Modal(document.getElementById('customBookingModal')); customBookingModal.toggle(); }, 50); }, makeCustomBooking(subtract = false) { if (subtract) { this.customBookingValue -= (this.customBookingValue * 2) } iziToast.show({ message: 'Buchung erfolgreich', color: 'green' }); this.bookTimeManually(this.selectedTracker, this.customBookingValue); }, getPortalLink (test = false) { let finalPortalName = this.portal.replaceAll('_', '-'); finalPortalName.replaceAll('-test', ''); finalPortalName += test ? '-test' : ''; return 'https://' + finalPortalName + '.vemap.com'; }, collectDataForDay(subtractDays = 0, customDate = false) { let day = moment().subtract(subtractDays, "days").format("MMM Do YY"); let collection = []; if (customDate) { day = moment(customDate).format("MMM Do YY"); } this.tickets.forEach((ticket) => { ticket.history.forEach((historyEntry) => { if (moment(historyEntry.trackingStarted).format("MMM Do YY") === day) { let newEntry = {}; Object.assign(newEntry, historyEntry); newEntry.ticket = ticket.number; let existingEntry = this.getCollectionItemWithValue(collection, 'ticket', ticket.number); if (existingEntry) { existingEntry.minutes = Number(existingEntry.minutes) + Number(newEntry.minutes); } else { collection.push(newEntry); } } }); }); this.archive.forEach((ticket) => { ticket.history.forEach((historyEntry) => { if (moment(historyEntry.trackingStarted).format("MMM Do YY") === day) { let newEntry = {}; Object.assign(newEntry, historyEntry); newEntry.ticket = ticket.number; let existingEntry = this.getCollectionItemWithValue(collection, 'ticket', ticket.number); if (existingEntry) { existingEntry.minutes = Number(existingEntry.minutes) + Number(newEntry.minutes); } else { collection.push(newEntry); } } }); }); return collection; }, getCollectionItemWithValue(collection, property, value) { let found = false; collection.forEach((item) => { if (item[property] === value) { found = item; } }) return found; }, sendPortalChangeRequest() { let vue = this; let publicDBParam = this.publicDB ? '&publicDB=1' : ''; axios.get( 'https://settings.vemap.docker/?portal2change=' + this.portal + publicDBParam ).then((response) => { // console.log(response) }).catch((error) => { // An error is expected here due to apache restarting iziToast.show({ message: 'Portal-Wechsel erfolgreich', color: 'green' }); if (vue.fun) { playSound(oneOf(vue.sounds.animals)); } vue.updateStorage(); }) }, importPortalsJson() { let vue = this; if (this.importStringForPortals !== '') { this.portals = JSON.parse(this.importStringForPortals); this.importStringForPortals = ''; iziToast.show({ message: 'Portalnamen importiert', color: 'green' }); this.updateStorage(); } }, createSnippet() { let newSnippet = { content: '', mode: 'htmlmixed', id: getRandomID() }; this.snippets.push(newSnippet); this.loadSnippet(this.snippets[this.snippets.length-1]); this.updateStorage(); }, loadSnippet(snippet) { setTimeout(() => { let element = document.getElementById(snippet.id); if (!element) { console.log('Textarea with id '+snippet.id+' not found'); return false; } snippet.mirrorInstance = CodeMirror.fromTextArea(element, { theme: 'darcula', lineNumbers: true, mode: snippet.mode, value: snippet.content }); setInterval(() => { snippet.content = snippet.mirrorInstance.doc.getValue(); }, 1000); }, 100); }, deleteSnippet(index) { this.snippets.splice(index, 1); iziToast.show({ message: 'Snippet wurde gelöscht', color: 'blue' }); this.updateStorage(); }, copySnippet(snippet) { let code = document.getElementById(snippet.id); code.select(); code.setSelectionRange(0, 99999); document.execCommand("copy"); iziToast.show({ message: 'Code kopiert!', color: 'green' }); }, checkTimeBoxes() { let vue = this; this.tickets.forEach((ticket) => { if (ticket.isTimeBox && !ticket.notificated && vue.timeBoxTimeLeft(ticket) <= 0) { ticket.notificated = true; vue.stopTracking(ticket); // alert('Zeit für "'+ticket.number+'" ist abgelaufen!'); if (Notification.permission === 'granted') { new Notification('Timebox zu Ende', { body: 'Zeit für "'+ticket.number+'" ist abgelaufen!', icon: '/timetrack/assets/img/favicon.ico' }); } else { iziToast.show({ message: 'Zeit für "'+ticket.number+'" ist abgelaufen!', color: 'red' }); } } }); }, timeBoxTimeLeft(ticket) { return Number(ticket.timeBoxMinutes - this.getTotalTime(ticket, true)); }, validateBooleans(value) { return value === 'true' || value === true; }, tellJoke(category, language = 'en') { let jokeService = new JokeService(category ?? undefined, language); jokeService.tell(); }, addTask() { if (this.newTaskInput.length <= 0 || this.newTaskInput.trim() === '') { return false; } if (!this.selectedTracker.tasks) { this.selectedTracker.tasks = []; } this.selectedTracker.tasks.push({ name: this.newTaskInput, done: false, created: moment(), percentDone: 0, finished: null }); this.newTaskInput = ''; this.updateStorage(); }, deleteTask(taskIndex) { this.selectedTracker.tasks.splice(taskIndex, 1) this.updateStorage(); }, toggleTask(task) { task.done = !task.done; if (task.done) { task.finished = moment(); task.percentDone = 100; } else { task.finished = null; task.percentDone = 0; } this.updateStorage(); }, checkForCompletionOfTask(task) { task.done = task.percentDone == 100; this.$forceUpdate(); this.updateStorage(); }, getOpenTasksForTracker(tasks) { let counter = 0; tasks.forEach((task) => { if (task.open) { counter++; } }); return counter; }, showOpenTasksForTracker(tasks) { let count = this.getOpenTasksForTracker(tasks); return count > 0 ? '('+count+' offen)' : ''; } }, beforeDestroy() { this.stopActiveTracker(); }, watch: { publicDB() { this.publicDB = this.validateBooleans(this.publicDB) this.updateStorage(); this.$forceUpdate(); }, showPT() { this.showPT = this.validateBooleans(this.showPT) this.updateStorage(); this.$forceUpdate(); }, dontShowMinutes() { this.dontShowMinutes = this.validateBooleans(this.dontShowMinutes) this.updateStorage(); this.$forceUpdate(); }, theme() { this.updateStorage(); this.$forceUpdate(); }, fun() { this.fun = this.validateBooleans(this.fun) this.updateStorage(); this.$forceUpdate(); } }, computed: { exportJson() { return JSON.stringify({ tickets: this.tickets, archive: this.archive, ticketSystemUrl: this.ticketSystemUrl, showPT: this.showPT, fun: this.fun, theme: this.theme }); } } }; function oneOf(collection) { return collection[Math.floor(Math.random()*collection.length)]; } function playSound(sound, extension = null) { let audio = new Audio('/timetrack/assets/audio/' + sound + (extension ?? '.mp3')); audio.play(); } function getRandomID() { let text = ''; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; for (let i = 0; i < 10; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } const TimeTrackApp = Vue.createApp(TimeTrack).mount('#root'); function iHateToHaveFun() { localStorage.setItem('noJokes', 1); }