diff --git a/package-lock.json b/package-lock.json index daee4bd..60ff0b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "WorkTime", - "version": "0.0.1", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "WorkTime", - "version": "0.0.1", + "version": "0.1.3", "dependencies": { "@angular/animations": "^18.0.0", "@angular/common": "^18.0.0", @@ -23,6 +23,7 @@ "@capacitor/keyboard": "6.0.2", "@capacitor/status-bar": "6.0.1", "@ionic/angular": "^8.0.0", + "chart.js": "^4.4.6", "ionicons": "^7.2.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -4285,6 +4286,12 @@ "tslib": "2" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -6839,6 +6846,18 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/chart.js": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/package.json b/package.json index 23652f4..690f164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WorkTime", - "version": "0.1.3", + "version": "0.2.1", "author": "Ionic Framework", "homepage": "https://ionicframework.com/", "scripts": { @@ -28,6 +28,7 @@ "@capacitor/keyboard": "6.0.2", "@capacitor/status-bar": "6.0.1", "@ionic/angular": "^8.0.0", + "chart.js": "^4.4.6", "ionicons": "^7.2.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", diff --git a/src/app/analysis/analysis.page.html b/src/app/analysis/analysis.page.html index 38b153e..6b24924 100644 --- a/src/app/analysis/analysis.page.html +++ b/src/app/analysis/analysis.page.html @@ -1,7 +1,7 @@ - Tab 2 + Analyse @@ -9,9 +9,63 @@ - Tab 2 + Analyse - + + Tag + + + + + + + + + + + + Noch zu arbeiten + Tagessoll erreicht! + + + + + + + + + + Auswertung + + + + + + + + {{formatTime(workTime)}} von {{formatTime(maxWorkTime)}} (+{{formatTime(driveTime)}} Reisezeit) + + + + + + + {{formatTime(pauseTime)}} von {{formatTime(maxPauseTime)}} + + + + + + + + {{formatTime(Math.max(combinedWorkTime - maxWorkTime, 0))}} von {{formatTime(desOverTime)}} ({{formatTime(maxOverTime)}} maximal) + + + + + + + diff --git a/src/app/analysis/analysis.page.scss b/src/app/analysis/analysis.page.scss index e69de29..3043f96 100644 --- a/src/app/analysis/analysis.page.scss +++ b/src/app/analysis/analysis.page.scss @@ -0,0 +1,19 @@ +.work-progress { + &::part(stream) { + animation: none; + -webkit-animation: none; + background-image: radial-gradient(ellipse at center, var(--background) 0%, var(--background) 30%, var(--background) 30%); + } + + &::part(track) { + background-color: var(--ion-color-warning); + } +} + +#overtime::part(track) { + background-color: var(--ion-color-tertiary-shade); +} + +ion-card-content ion-item { + --background: unset; +} diff --git a/src/app/analysis/analysis.page.ts b/src/app/analysis/analysis.page.ts index 6d8e213..2ea46e3 100644 --- a/src/app/analysis/analysis.page.ts +++ b/src/app/analysis/analysis.page.ts @@ -1,16 +1,194 @@ -import { Component } from '@angular/core'; -import { IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/angular/standalone'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import { + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonItem, + IonLabel, + IonDatetimeButton, + IonModal, + IonDatetime, + IonCard, + IonCardHeader, + IonCardTitle, + IonCardContent, + IonCardSubtitle, IonList, IonIcon, IonProgressBar +} from '@ionic/angular/standalone'; import { ExploreContainerComponent } from '../explore-container/explore-container.component'; +import {FormsModule} from "@angular/forms"; +import {TimeEntry} from "../../models/timeEntry"; +import {TimeService} from "../../services/time.service"; +import {Chart} from "chart.js/auto"; +import {addIcons} from "ionicons"; +import {briefcase, card, pizza} from "ionicons/icons"; +import {NgIf} from "@angular/common"; @Component({ selector: 'app-tab2', templateUrl: 'analysis.page.html', styleUrls: ['analysis.page.scss'], standalone: true, - imports: [IonHeader, IonToolbar, IonTitle, IonContent, ExploreContainerComponent] + imports: [IonHeader, IonToolbar, IonTitle, IonContent, ExploreContainerComponent, IonItem, IonLabel, IonDatetimeButton, IonModal, IonDatetime, FormsModule, IonCard, IonCardHeader, IonCardTitle, IonCardContent, IonCardSubtitle, IonList, IonIcon, IonProgressBar, NgIf] }) export class AnalysisPage { + public currentDate: any; + public timeData: TimeEntry[] = []; - constructor() {} + public workTime: number = 0; + public pauseTime: number = 0; + public driveTime: number = 0; + public combinedWorkTime: number = 0; + public maxWorkTime: number = 420; + public maxPauseTime: number = 30; + public maxOverTime: number = 60; + public desOverTime: number = 30; + + // @ts-ignore + @ViewChild('chart') chartRef: ElementRef; + private chart: any; + + constructor(private time: TimeService) { + addIcons({briefcase, pizza, card}) + } + + ionViewDidEnter() { + this.updateCurrentData(); + } + + public updateCurrentData() { + this.timeData = this.time.getEntries(this.currentDate); + this.workTime = 0; + this.pauseTime = 0; + this.driveTime = 0; + this.combinedWorkTime = 0; + + if (this.timeData.length < 2) { + this.showEmptyChart(); + return; + } + + for (let i = 1; i < this.timeData.length; i++) { + const start = this.timeData[i - 1]; + const end = this.timeData[i]; + const diff = this.time.calculateTimespanInMinutes(start, end); + + if (start.type == 'start-drive' && end.type == 'end-drive') { + this.driveTime += diff; + } + else if (start.type === 'login') { + this.workTime += diff; + } + else { + this.pauseTime += diff; + } + } + this.combinedWorkTime = this.workTime + this.driveTime; + + if (this.combinedWorkTime < 360) { + this.maxPauseTime = 0; + } + if (this.combinedWorkTime >= 360) { // 6h + this.maxPauseTime = 30; + } + if (this.combinedWorkTime >= 540) { // 9h + this.maxPauseTime = 45; + } + + this.updateChart(); + } + + public updateChart() { + const style = getComputedStyle(document.body); + const textColor = style.getPropertyValue('--ion-text-color'); + const workColor = style.getPropertyValue('--ion-color-primary'); + const driveColor = style.getPropertyValue('--ion-color-secondary'); + const remainColor = style.getPropertyValue('--ion-card-background'); + const overColor = style.getPropertyValue('--ion-color-tertiary'); + const overColorWarn = style.getPropertyValue('--ion-color-warning'); + + let overData: number[] = []; + let overLabels: string[] = []; + let overColors: string[] = []; + + if (this.combinedWorkTime > this.maxWorkTime) { + const overTime = this.combinedWorkTime - this.maxWorkTime; + const overPercentage = overTime / this.desOverTime; + + overData.push(this.combinedWorkTime * overPercentage); + overLabels.push('Überstunden'); + overColors.push(overPercentage > 1 ? overColorWarn : overColor); + } + + this.chart?.destroy(); + Chart.defaults.color = textColor; + this.chart = new Chart(this.chartRef.nativeElement, { + type: 'doughnut', + data: { + labels: [ + ...overLabels, + 'Arbeitszeit', + 'Dienstreise' + ], + datasets: [{ + label: 'Zeit', + data: [...overData, this.workTime, this.driveTime, Math.max(this.maxWorkTime - this.combinedWorkTime, 0)], + backgroundColor: [ + ...overColors, + workColor, + driveColor, + remainColor + ], + }] + }, + options: { + events: [], + plugins: { + legend: { + display: this.driveTime > 0 || this.combinedWorkTime > this.maxWorkTime + } + } + } + }); + } + + public showEmptyChart() { + const style = getComputedStyle(document.body); + const remainColor = style.getPropertyValue('--ion-card-background'); + + this.chart?.destroy(); + this.chart = new Chart(this.chartRef.nativeElement, { + type: 'doughnut', + data: { + labels: [], + datasets: [{ + label: 'Zeit', + data: [100], + backgroundColor: [ + remainColor + ], + }] + }, + options: { + events: [], + plugins: { + legend: { + display: this.driveTime > 0 || this.combinedWorkTime > this.maxWorkTime + } + } + } + }); + } + + public formatTime(time: number): string { + const hours = Math.floor(time / 60); + const minutes = time % 60; + + let result = hours < 10 ? "0" + hours + ":" : hours.toString() + ":"; + result += minutes < 10 ? "0" + minutes : minutes.toString(); + return result; + } + + protected readonly Math = Math; } diff --git a/src/app/tabs/tabs.page.html b/src/app/tabs/tabs.page.html index 595eaf1..4fa7ca3 100644 --- a/src/app/tabs/tabs.page.html +++ b/src/app/tabs/tabs.page.html @@ -5,7 +5,7 @@ Erfassen - + Analyse diff --git a/src/app/time/time.page.ts b/src/app/time/time.page.ts index b1f883f..75ffdf0 100644 --- a/src/app/time/time.page.ts +++ b/src/app/time/time.page.ts @@ -15,6 +15,7 @@ import {NgClass, NgForOf, NgIf} from "@angular/common"; import {addIcons} from "ionicons"; import {add} from "ionicons/icons"; import {FormsModule} from "@angular/forms"; +import {TimeService} from "../../services/time.service"; @Component({ selector: 'app-tab1', @@ -33,22 +34,15 @@ export class TimePage { public modalDate: any; public currentDate: any; - constructor() { - const savedData = localStorage.getItem("time-data"); + constructor(private timeService: TimeService) { + this.data = timeService.loadEntries(); - if (savedData != null) { - this.data = JSON.parse(savedData as string); - - for (let entry of this.data) { - entry.registeredAt = new Date(entry.registeredAt); - entry.registeredAt.toLocaleTimeString(); - - this.shouldAnimate.push(false); - } - - this.updateCurrentAction(); + for (let i = 0; i < this.data.length; i++) { + this.shouldAnimate.push(false); } + this.updateCurrentAction(); + addIcons({add}); } @@ -80,18 +74,15 @@ export class TimePage { text = "Dienstreise "; } - return text + `(${this.calculateTimespan(entry1.registeredAt, entry2.registeredAt)})`; + return text + `(${this.calculateTimespan(entry1, entry2)})`; } - private calculateTimespan(start: Date, end: Date): string { - const startSeconds: number = (start.getHours() * 3600) + (start.getMinutes() * 60) + start.getSeconds(); - const endSeconds: number = (end.getHours() * 3600) + (end.getMinutes() * 60) + end.getSeconds(); + private calculateTimespan(start: TimeEntry, end: TimeEntry): string { + const time = this.timeService.calculateTimespanInMinutes(start, end); + const hours = Math.floor(time / 60); + const minutes = time % 60; - const difference = endSeconds - startSeconds; - const diffHours = Math.floor(difference / 3600.00); - const diffMinutes = Math.floor((difference % 3600) / 60.00); - - return this.formatEntry(diffHours, diffMinutes); + return this.formatEntry(hours, minutes); } public formatEntry(hours: number, minutes: number): string { @@ -165,7 +156,7 @@ export class TimePage { } private saveData(): void { - localStorage.setItem("time-data", JSON.stringify(this.data)); + this.timeService.saveEntries(this.data); } public openModal(): void { diff --git a/src/services/time.service.ts b/src/services/time.service.ts new file mode 100644 index 0000000..5b24684 --- /dev/null +++ b/src/services/time.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import {TimeEntry} from "../models/timeEntry"; + +@Injectable({ + providedIn: 'root' +}) +export class TimeService { + + constructor() { } + + public calculateTimespanInMinutes(start: TimeEntry, end: TimeEntry): number { + const startSeconds: number = (start.registeredAt.getHours() * 3600) + (start.registeredAt.getMinutes() * 60) + start.registeredAt.getSeconds(); + const endSeconds: number = (end.registeredAt.getHours() * 3600) + (end.registeredAt.getMinutes() * 60) + end.registeredAt.getSeconds(); + + const difference = endSeconds - startSeconds; + const diffHours = Math.floor(difference / 3600.00); + const diffMinutes = Math.floor((difference % 3600) / 60.00); + + return diffMinutes + (diffHours * 60); + } + + public getEntries(day: any): TimeEntry[] { + const today = new Date(day || Date.now()).toLocaleDateString(); + return this.loadEntries().filter(entry => entry.registeredAt.toLocaleDateString() === today); + } + + public loadEntries(): TimeEntry[] { + const savedData = localStorage.getItem("time-data"); + + if (savedData != null) { + const data = JSON.parse(savedData as string) as TimeEntry[]; + + for (let entry of data) { + entry.registeredAt = new Date(entry.registeredAt); + } + + return data; + } + + return []; + } + + public saveEntries(entries: TimeEntry[]): void { + localStorage.setItem("time-data", JSON.stringify(entries)); + } +}