diff --git a/backend/src/main/java/core/api/controller/StatisticController.java b/backend/src/main/java/core/api/controller/StatisticController.java index 5abfe02..9558db0 100644 --- a/backend/src/main/java/core/api/controller/StatisticController.java +++ b/backend/src/main/java/core/api/controller/StatisticController.java @@ -1,21 +1,30 @@ package core.api.controller; +import core.api.models.auth.SimpleStatusResponse; +import core.api.models.timemanager.history.TaskgroupActivityInfo; import core.api.models.timemanager.history.WorkingStatus; +import core.api.models.timemanager.taskgroup.TaskgroupEntityInfo; import core.entities.timemanager.AbstractSchedule; +import core.entities.timemanager.Task; +import core.entities.timemanager.Taskgroup; +import core.services.PermissionResult; +import core.services.ServiceExitCode; import core.services.TaskScheduleService; +import core.services.TaskgroupService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.parameters.P; +import org.springframework.web.bind.annotation.*; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; +import java.util.Map; @CrossOrigin(origins = "*", maxAge = 3600) @RestController @@ -23,6 +32,7 @@ import java.util.List; public class StatisticController { @Autowired private TaskScheduleService taskScheduleService; + @Autowired private TaskgroupService taskgroupService; @GetMapping("/history/workingStatus") public ResponseEntity getWorkingStatus() { @@ -39,4 +49,17 @@ public class StatisticController { return ResponseEntity.ok(new WorkingStatus(missedSchedules, activeTime)); } + + @GetMapping("/statistics/taskgroup-activity/{taskgroupID}/{startingDate}/{endingDate}/{includeSubTaskgroups}") + public ResponseEntity getTaskgroupActivity(@PathVariable long taskgroupID, @PathVariable String startingDate, @PathVariable String endingDate, @PathVariable boolean includeSubTaskgroups){ + PermissionResult taskgroupPermissionResult = taskgroupService.getTaskgroupByIDAndUsername(taskgroupID, SecurityContextHolder.getContext().getAuthentication().getName()); + if(!taskgroupPermissionResult.isHasPermissions()) { + return ResponseEntity.status(403).body(new SimpleStatusResponse("failed")); + } else if(taskgroupPermissionResult.getExitCode() == ServiceExitCode.MISSING_ENTITY) { + return ResponseEntity.status(404).body(new SimpleStatusResponse("failed")); + } + + return ResponseEntity.ok(taskgroupPermissionResult.getResult().calcActivityInfo(includeSubTaskgroups, + LocalDate.parse(startingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")), LocalDate.parse(endingDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")))); + } } diff --git a/backend/src/main/java/core/api/controller/TaskgroupController.java b/backend/src/main/java/core/api/controller/TaskgroupController.java index 6de2010..e9cdea2 100644 --- a/backend/src/main/java/core/api/controller/TaskgroupController.java +++ b/backend/src/main/java/core/api/controller/TaskgroupController.java @@ -1,10 +1,7 @@ package core.api.controller; import core.api.models.auth.SimpleStatusResponse; -import core.api.models.timemanager.taskgroup.RecursiveTaskgroupInfo; -import core.api.models.timemanager.taskgroup.TaskgroupDetailInfo; -import core.api.models.timemanager.taskgroup.TaskgroupEntityInfo; -import core.api.models.timemanager.taskgroup.TaskgroupFieldInfo; +import core.api.models.timemanager.taskgroup.*; import core.entities.timemanager.Taskgroup; import core.services.*; import org.springframework.beans.factory.annotation.Autowired; @@ -116,4 +113,10 @@ public class TaskgroupController { taskService.clearTasks(taskgroupPermissionResult.getResult()); return ResponseEntity.ok(new SimpleStatusResponse("success")); } + + @GetMapping("/taskgroups/path") + public ResponseEntity getTaskgroupPaths() { + List taskgroups = taskgroupService.getTaskgroupsByUser(SecurityContextHolder.getContext().getAuthentication().getName()); + return ResponseEntity.ok(taskgroups.stream().map(TaskgroupPathInfo::new)); + } } diff --git a/backend/src/main/java/core/api/models/timemanager/history/TaskgroupActivityInfo.java b/backend/src/main/java/core/api/models/timemanager/history/TaskgroupActivityInfo.java new file mode 100644 index 0000000..2441617 --- /dev/null +++ b/backend/src/main/java/core/api/models/timemanager/history/TaskgroupActivityInfo.java @@ -0,0 +1,20 @@ +package core.api.models.timemanager.history; + +import com.fasterxml.jackson.annotation.JsonProperty; +import core.api.models.timemanager.taskgroup.TaskgroupEntityInfo; + +import java.time.LocalDate; + +public class TaskgroupActivityInfo { + + @JsonProperty + private LocalDate date; + + @JsonProperty + private int activeMinutes; + + public TaskgroupActivityInfo(int activeMinutes, LocalDate localDate) { + this.date = localDate; + this.activeMinutes = activeMinutes; + } +} diff --git a/backend/src/main/java/core/api/models/timemanager/taskgroup/TaskgroupPathInfo.java b/backend/src/main/java/core/api/models/timemanager/taskgroup/TaskgroupPathInfo.java new file mode 100644 index 0000000..2e5d9a4 --- /dev/null +++ b/backend/src/main/java/core/api/models/timemanager/taskgroup/TaskgroupPathInfo.java @@ -0,0 +1,26 @@ +package core.api.models.timemanager.taskgroup; + +import com.fasterxml.jackson.annotation.JsonProperty; +import core.entities.timemanager.Taskgroup; + +import java.util.ArrayList; +import java.util.List; + +public class TaskgroupPathInfo { + + @JsonProperty + private String taskgroupPath; + + @JsonProperty + private List directChildren; + public TaskgroupPathInfo(Taskgroup taskgroup) { + List taskgroupPath = Taskgroup.getAncestorList(taskgroup); + StringBuilder stringBuilder = new StringBuilder(); + for(Taskgroup taskgroupPathEntity : taskgroupPath) { + stringBuilder.append(taskgroupPathEntity.getTaskgroupName()); + stringBuilder.append("/"); + } + this.taskgroupPath = stringBuilder.substring(0, stringBuilder.length()-1); + directChildren = taskgroup.getChildren().stream().map(TaskgroupEntityInfo::new).toList(); + } +} diff --git a/backend/src/main/java/core/entities/timemanager/AbstractSchedule.java b/backend/src/main/java/core/entities/timemanager/AbstractSchedule.java index 4a07efb..f7f4de1 100644 --- a/backend/src/main/java/core/entities/timemanager/AbstractSchedule.java +++ b/backend/src/main/java/core/entities/timemanager/AbstractSchedule.java @@ -93,6 +93,10 @@ public abstract class AbstractSchedule { return startTime != null && stopTime == null; } + public boolean isCompleted() { + return startTime != null && stopTime != null; + } + public int getActiveTime() { if(startTime == null) { return 0; diff --git a/backend/src/main/java/core/entities/timemanager/Task.java b/backend/src/main/java/core/entities/timemanager/Task.java index c6bfe1d..1858ca8 100644 --- a/backend/src/main/java/core/entities/timemanager/Task.java +++ b/backend/src/main/java/core/entities/timemanager/Task.java @@ -188,4 +188,14 @@ public class Task { public void setFinishable(boolean finishable) { this.finishable = finishable; } + + public int calcOverallActivityInfo(LocalDate date) { + int activeMinutes = 0; + for(AbstractSchedule schedule : getBasicTaskSchedules()) { + if(schedule.isCompleted() && schedule.getStartTime().toLocalDate().isEqual(date)) { + activeMinutes += schedule.calcActiveMinutes(); + } + } + return activeMinutes; + } } diff --git a/backend/src/main/java/core/entities/timemanager/Taskgroup.java b/backend/src/main/java/core/entities/timemanager/Taskgroup.java index bdf42ca..016c607 100644 --- a/backend/src/main/java/core/entities/timemanager/Taskgroup.java +++ b/backend/src/main/java/core/entities/timemanager/Taskgroup.java @@ -1,9 +1,12 @@ package core.entities.timemanager; +import core.api.models.timemanager.history.TaskgroupActivityInfo; +import core.api.models.timemanager.taskgroup.TaskgroupEntityInfo; import core.entities.User; import javax.persistence.*; import javax.validation.constraints.NotBlank; +import java.time.LocalDate; import java.util.*; @Entity @@ -132,4 +135,55 @@ public class Taskgroup { public int hashCode() { return Objects.hash(taskgroupID); } + + public List calcActivityInfo(boolean includeChildTasks, LocalDate start, LocalDate end) { + HashMap activityInfos = new HashMap<>(); + if(includeChildTasks) { + + Queue queue = new LinkedList<>(children); + while(!queue.isEmpty()) { + Taskgroup childTraskgroup = queue.poll(); + LocalDate currentDate = start; + while(!currentDate.isAfter(end)) { + int activeMinutes = 0; + for(Task task : childTraskgroup.getTasks()) { + activeMinutes += task.calcOverallActivityInfo(currentDate); + } + + if(activityInfos.containsKey(currentDate)) { + activityInfos.put(currentDate, activityInfos.get(currentDate) + activeMinutes); + } else { + activityInfos.put(currentDate, activeMinutes); + } + + currentDate = currentDate.plusDays(1); + } + + + queue.addAll(childTraskgroup.getChildren()); + } + } + + LocalDate currentDate = start; + while(!currentDate.isAfter(end)) { + int activeMinutes = 0; + for(Task task : tasks) { + activeMinutes += task.calcOverallActivityInfo(currentDate); + } + + if(activityInfos.containsKey(currentDate)) { + activityInfos.put(currentDate, activityInfos.get(currentDate) + activeMinutes); + } else { + activityInfos.put(currentDate, activeMinutes); + } + + currentDate = currentDate.plusDays(1); + } + + List taskgroupActivityInfos = new ArrayList<>(); + for(Map.Entry entry : activityInfos.entrySet()) { + taskgroupActivityInfos.add(new TaskgroupActivityInfo(entry.getValue(), entry.getKey())); + } + return taskgroupActivityInfos; + } } diff --git a/backend/src/main/java/core/repositories/timemanager/ScheduleRepository.java b/backend/src/main/java/core/repositories/timemanager/ScheduleRepository.java index 92ea8b6..df72a88 100644 --- a/backend/src/main/java/core/repositories/timemanager/ScheduleRepository.java +++ b/backend/src/main/java/core/repositories/timemanager/ScheduleRepository.java @@ -1,10 +1,13 @@ package core.repositories.timemanager; import core.entities.timemanager.AbstractSchedule; +import core.entities.timemanager.Taskgroup; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Optional; diff --git a/backend/src/main/java/core/services/TaskgroupService.java b/backend/src/main/java/core/services/TaskgroupService.java index 3e0d725..ebbc078 100644 --- a/backend/src/main/java/core/services/TaskgroupService.java +++ b/backend/src/main/java/core/services/TaskgroupService.java @@ -107,4 +107,8 @@ public class TaskgroupService { public void deleteTaskgroupByUser(User user) { taskgroupRepository.deleteAllByUser(user); } + + public List getTaskgroupsByUser(String name) { + return taskgroupRepository.findAllByUser(name); + } } diff --git a/frontend/angular.json b/frontend/angular.json index 6e7641a..1a103b3 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -31,7 +31,9 @@ "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.css" ], - "scripts": [] + "scripts": [ + "node_modules/apexcharts/dist/apexcharts.min.js" + ] }, "configurations": { "production": { @@ -100,7 +102,9 @@ "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.css" ], - "scripts": [] + "scripts": [ + "node_modules/apexcharts/dist/apexcharts.min.js" + ] } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2bf9e76..5f35219 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,10 +22,13 @@ "@angular/platform-browser-dynamic": "^16.2.7", "@angular/router": "^16.2.7", "angular-calendar": "^0.31.0", + "apexcharts": "^3.44.0", "date-fns": "^2.29.3", "luxon": "^3.4.3", "moment": "^2.29.4", + "ng-apexcharts": "^1.8.0", "ngx-material-timepicker": "^13.1.1", + "ngx-slider-v2": "^16.0.2", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.13.3" @@ -4738,6 +4741,11 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5036,6 +5044,20 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.44.0.tgz", + "integrity": "sha512-u7Xzrbcxc2yWznN78Jh5NMCYVAsWDfBjRl5ea++rVzFAqjU2hLz4RgKIFwYOBDRQtW1e/Qz8azJTqIJ1+Vu9Qg==", + "dependencies": { + "@yr/monotone-cubic-spline": "^1.0.3", + "svg.draggable.js": "^2.2.2", + "svg.easing.js": "^2.0.0", + "svg.filter.js": "^2.0.2", + "svg.pathmorphing.js": "^0.1.3", + "svg.resize.js": "^1.4.3", + "svg.select.js": "^3.0.1" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -6334,12 +6356,25 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-it": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/detect-it/-/detect-it-4.0.1.tgz", + "integrity": "sha512-dg5YBTJYvogK1+dA2mBUDKzOWfYZtHVba89SyZUhc4+e3i2tzgjANFg5lDRCd3UOtRcw00vUTMK8LELcMdicug==" + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "dev": true }, + "node_modules/detect-passive-events": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-passive-events/-/detect-passive-events-2.0.3.tgz", + "integrity": "sha512-QN/1X65Axis6a9D8qg8Py9cwY/fkWAmAH/edTbmLMcv4m5dboLJ7LcAi8CfaCON2tjk904KwKX/HTdsHC6yeRg==", + "dependencies": { + "detect-it": "^4.0.1" + } + }, "node_modules/di": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", @@ -9753,6 +9788,20 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/ng-apexcharts": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ng-apexcharts/-/ng-apexcharts-1.8.0.tgz", + "integrity": "sha512-NwJuMLHoLm52LSzM08RXV6oOOTyUYREAV53WHVGs+L2qi8UWbxCz19hX0kk+F/xFLEhhuiLegO3T1v30jLbKSQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": ">=13.0.0", + "@angular/core": ">=13.0.0", + "apexcharts": "^3.41.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/ngx-material-timepicker": { "version": "13.1.1", "resolved": "https://registry.npmjs.org/ngx-material-timepicker/-/ngx-material-timepicker-13.1.1.tgz", @@ -9765,6 +9814,21 @@ "luxon": ">= 1.24.0" } }, + "node_modules/ngx-slider-v2": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-16.0.2.tgz", + "integrity": "sha512-Lpl7SlErL+tJJvTRZYdyZoXTThKN8Ro1z3vscJQ1O5azHXwvbv3pnTcsOwY4ltfaP+dpzY27KL1QXyDr6QMaxQ==", + "dependencies": { + "detect-passive-events": "^2.0.3", + "rxjs": "^7.4.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0", + "@angular/forms": "^16.0.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", @@ -12181,6 +12245,89 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg.draggable.js": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", + "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==", + "dependencies": { + "svg.js": "^2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.easing.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz", + "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==", + "dependencies": { + "svg.js": ">=2.3.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.filter.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz", + "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz", + "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==" + }, + "node_modules/svg.pathmorphing.js": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz", + "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==", + "dependencies": { + "svg.js": "^2.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz", + "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==", + "dependencies": { + "svg.js": "^2.6.5", + "svg.select.js": "^2.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js/node_modules/svg.select.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", + "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.select.js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz", + "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==", + "dependencies": { + "svg.js": "^2.6.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index eabd35d..b83dad7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,10 +24,13 @@ "@angular/platform-browser-dynamic": "^16.2.7", "@angular/router": "^16.2.7", "angular-calendar": "^0.31.0", + "apexcharts": "^3.44.0", "date-fns": "^2.29.3", "luxon": "^3.4.3", "moment": "^2.29.4", + "ng-apexcharts": "^1.8.0", "ngx-material-timepicker": "^13.1.1", + "ngx-slider-v2": "^16.0.2", "rxjs": "~7.5.0", "tslib": "^2.3.0", "zone.js": "~0.13.3" diff --git a/frontend/src/api/.openapi-generator/FILES b/frontend/src/api/.openapi-generator/FILES index 9ff84f1..fe29b43 100644 --- a/frontend/src/api/.openapi-generator/FILES +++ b/frontend/src/api/.openapi-generator/FILES @@ -47,9 +47,11 @@ model/taskOverviewInfo.ts model/taskScheduleStopResponse.ts model/taskShortInfo.ts model/taskTaskgroupInfo.ts +model/taskgroupActivityInfo.ts model/taskgroupDetailInfo.ts model/taskgroupEntityInfo.ts model/taskgroupFieldInfo.ts +model/taskgroupPathInfo.ts model/userAddInfo.ts model/userInfo.ts model/userUpdateInfo.ts diff --git a/frontend/src/api/api/history.service.ts b/frontend/src/api/api/history.service.ts index 46e7755..bc7e6bc 100644 --- a/frontend/src/api/api/history.service.ts +++ b/frontend/src/api/api/history.service.ts @@ -20,6 +20,7 @@ import { Observable } from 'rxjs'; import { ScheduleStatus } from '../model/models'; import { SimpleStatusResponse } from '../model/models'; +import { TaskgroupActivityInfo } from '../model/models'; import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; import { Configuration } from '../configuration'; @@ -141,4 +142,73 @@ export class HistoryService { ); } + /** + * @param taskgroupID internal id of taskgroup + * @param startingDate starting date + * @param endingDate starting date + * @param includeSubTaskgroups determines whether to include subtaskgroups or not + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet(taskgroupID: number, startingDate: string, endingDate: string, includeSubTaskgroups: boolean, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>; + public statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet(taskgroupID: number, startingDate: string, endingDate: string, includeSubTaskgroups: boolean, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>>; + public statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet(taskgroupID: number, startingDate: string, endingDate: string, includeSubTaskgroups: boolean, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>>; + public statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet(taskgroupID: number, startingDate: string, endingDate: string, includeSubTaskgroups: boolean, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable { + if (taskgroupID === null || taskgroupID === undefined) { + throw new Error('Required parameter taskgroupID was null or undefined when calling statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet.'); + } + if (startingDate === null || startingDate === undefined) { + throw new Error('Required parameter startingDate was null or undefined when calling statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet.'); + } + if (endingDate === null || endingDate === undefined) { + throw new Error('Required parameter endingDate was null or undefined when calling statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet.'); + } + if (includeSubTaskgroups === null || includeSubTaskgroups === undefined) { + throw new Error('Required parameter includeSubTaskgroups was null or undefined when calling statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet.'); + } + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (API_TOKEN) required + localVarCredential = this.configuration.lookupCredential('API_TOKEN'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + + let responseType_: 'text' | 'json' = 'json'; + if(localVarHttpHeaderAcceptSelected && localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } + + return this.httpClient.get>(`${this.configuration.basePath}/statistics/taskgroup-activity/${encodeURIComponent(String(taskgroupID))}/${encodeURIComponent(String(startingDate))}/${encodeURIComponent(String(endingDate))}/${encodeURIComponent(String(includeSubTaskgroups))}`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + reportProgress: reportProgress + } + ); + } + } diff --git a/frontend/src/api/api/taskgroup.service.ts b/frontend/src/api/api/taskgroup.service.ts index 8433f82..41da811 100644 --- a/frontend/src/api/api/taskgroup.service.ts +++ b/frontend/src/api/api/taskgroup.service.ts @@ -25,6 +25,7 @@ import { SimpleStatusResponse } from '../model/models'; import { TaskgroupDetailInfo } from '../model/models'; import { TaskgroupEntityInfo } from '../model/models'; import { TaskgroupFieldInfo } from '../model/models'; +import { TaskgroupPathInfo } from '../model/models'; import { BASE_PATH, COLLECTION_FORMATS } from '../variables'; import { Configuration } from '../configuration'; @@ -201,6 +202,61 @@ export class TaskgroupService { ); } + /** + * lists all taskgrouppaths + * lists all taskgroup-paths + * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. + * @param reportProgress flag to report request and response progress. + */ + public taskgroupsPathGet(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>; + public taskgroupsPathGet(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>>; + public taskgroupsPathGet(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable>>; + public taskgroupsPathGet(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable { + + let localVarHeaders = this.defaultHeaders; + + let localVarCredential: string | undefined; + // authentication (API_TOKEN) required + localVarCredential = this.configuration.lookupCredential('API_TOKEN'); + if (localVarCredential) { + localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential); + } + + let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept; + if (localVarHttpHeaderAcceptSelected === undefined) { + // to determine the Accept header + const httpHeaderAccepts: string[] = [ + 'application/json' + ]; + localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); + } + if (localVarHttpHeaderAcceptSelected !== undefined) { + localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected); + } + + let localVarHttpContext: HttpContext | undefined = options && options.context; + if (localVarHttpContext === undefined) { + localVarHttpContext = new HttpContext(); + } + + + let responseType_: 'text' | 'json' = 'json'; + if(localVarHttpHeaderAcceptSelected && localVarHttpHeaderAcceptSelected.startsWith('text')) { + responseType_ = 'text'; + } + + return this.httpClient.get>(`${this.configuration.basePath}/taskgroups/path`, + { + context: localVarHttpContext, + responseType: responseType_, + withCredentials: this.configuration.withCredentials, + headers: localVarHeaders, + observe: observe, + reportProgress: reportProgress + } + ); + } + /** * creates taskgroup * creates taskgroup diff --git a/frontend/src/api/model/models.ts b/frontend/src/api/model/models.ts index e94238f..1411e50 100644 --- a/frontend/src/api/model/models.ts +++ b/frontend/src/api/model/models.ts @@ -30,9 +30,11 @@ export * from './taskOverviewInfo'; export * from './taskScheduleStopResponse'; export * from './taskShortInfo'; export * from './taskTaskgroupInfo'; +export * from './taskgroupActivityInfo'; export * from './taskgroupDetailInfo'; export * from './taskgroupEntityInfo'; export * from './taskgroupFieldInfo'; +export * from './taskgroupPathInfo'; export * from './userAddInfo'; export * from './userInfo'; export * from './userUpdateInfo'; diff --git a/frontend/src/api/model/taskgroupActivityInfo.ts b/frontend/src/api/model/taskgroupActivityInfo.ts new file mode 100644 index 0000000..fc6a973 --- /dev/null +++ b/frontend/src/api/model/taskgroupActivityInfo.ts @@ -0,0 +1,21 @@ +/** + * API Title + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface TaskgroupActivityInfo { + date: string; + /** + * Number of minutes the task was active + */ + activeMinutes: number; +} + diff --git a/frontend/src/api/model/taskgroupPathInfo.ts b/frontend/src/api/model/taskgroupPathInfo.ts new file mode 100644 index 0000000..b13e526 --- /dev/null +++ b/frontend/src/api/model/taskgroupPathInfo.ts @@ -0,0 +1,22 @@ +/** + * API Title + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +import { TaskgroupEntityInfo } from './taskgroupEntityInfo'; + + +export interface TaskgroupPathInfo { + /** + * TaskgroupPath + */ + taskgroupPath: string; + directChildren: Array; +} + diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 540fa74..37b2049 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -15,6 +15,7 @@ import {DraggableSchedulerComponent} from "./schedules/draggable-scheduler/dragg import { ForgottenTaskStartDialogComponent } from "./dashboard/forgotten-task-start-dialog/forgotten-task-start-dialog.component"; +import {TaskgroupActivityComponent} from "./statistics/taskgroup-activity/taskgroup-activity.component"; const routes: Routes = [ {path: '', component: MainComponent}, @@ -30,7 +31,8 @@ const routes: Routes = [ {path: 'upcoming', component: UpcomingTaskOverviewComponent}, {path: 'active', component: ActiveTaskOverviewComponent}, {path: 'scheduler', component: DraggableSchedulerComponent}, - {path: 'forgotten', component: ForgottenTaskStartDialogComponent} + {path: 'forgotten', component: ForgottenTaskStartDialogComponent}, + {path: 'statistics/taskgroup-activity', component: TaskgroupActivityComponent} ]; @NgModule({ diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index ab245f6..58df28c 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -2,6 +2,7 @@ TimeManager + @@ -10,6 +11,10 @@ + + + + diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 24254ec..ed4e568 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -80,6 +80,9 @@ import { DateTimePickerComponent } from './date-time-picker/date-time-picker.com import {MatSliderModule} from "@angular/material/slider"; import {MatLegacySliderModule} from "@angular/material/legacy-slider"; import { DraggableSchedulerComponent } from './schedules/draggable-scheduler/draggable-scheduler.component'; +import { TaskgroupActivityComponent } from './statistics/taskgroup-activity/taskgroup-activity.component'; +import {NgxSliderModule} from "ngx-slider-v2"; +import {NgApexchartsModule} from "ng-apexcharts"; @NgModule({ declarations: [ AppComponent, @@ -120,6 +123,7 @@ import { DraggableSchedulerComponent } from './schedules/draggable-scheduler/dra AdvancedSchedulerComponent, DateTimePickerComponent, DraggableSchedulerComponent, + TaskgroupActivityComponent, ], imports: [ BrowserModule, @@ -158,7 +162,9 @@ import { DraggableSchedulerComponent } from './schedules/draggable-scheduler/dra MatTreeModule, MatAutocompleteModule, NgxMaterialTimepickerModule, - MatSliderModule + MatSliderModule, + NgxSliderModule, + NgApexchartsModule, ], providers: [ HttpClientModule, diff --git a/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.css b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.css new file mode 100644 index 0000000..bf9df91 --- /dev/null +++ b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.css @@ -0,0 +1,66 @@ +.container { + margin: 20px auto; + width: 80%; +} + +.spacer { + margin-bottom: 2.5%; +} + + +@media screen and (max-width: 600px) { + .container { + width: 100%; + margin: 20px 10px; + } +} + +#date-range-selector { + width: 100%; +} + +::ng-deep { + .custom-slider .ngx-slider .ngx-slider-bar { + background: #ffe4d1; + height: 2px; + } + .custom-slider .ngx-slider .ngx-slider-selection { + background: orange; + } + + .custom-slider .ngx-slider .ngx-slider-pointer { + width: 8px; + height: 16px; + top: auto; /* to remove the default positioning */ + bottom: 0; + background-color: #333; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + } + + .custom-slider .ngx-slider .ngx-slider-pointer:after { + display: none; + } + + .custom-slider .ngx-slider .ngx-slider-bubble { + bottom: 14px; + } + + .custom-slider .ngx-slider .ngx-slider-limit { + font-weight: bold; + color: orange; + } + + .custom-slider .ngx-slider .ngx-slider-tick { + width: 1px; + height: 10px; + margin-left: 4px; + border-radius: 0; + background: #ffe4d1; + top: -1px; + } + + .custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected { + background: orange; + } +} diff --git a/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.html b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.html new file mode 100644 index 0000000..39ee84d --- /dev/null +++ b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.html @@ -0,0 +1,28 @@ +
+ + + Toppings + + {{topping.taskgroupPath}} + + + + Toppings + + {{topping}} + + + +
+ +
+
+ +
+
diff --git a/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.spec.ts b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.spec.ts new file mode 100644 index 0000000..1c8faeb --- /dev/null +++ b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TaskgroupActivityComponent } from './taskgroup-activity.component'; + +describe('TaskgroupActivityComponent', () => { + let component: TaskgroupActivityComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TaskgroupActivityComponent] + }); + fixture = TestBed.createComponent(TaskgroupActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.ts b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.ts new file mode 100644 index 0000000..caf6965 --- /dev/null +++ b/frontend/src/app/statistics/taskgroup-activity/taskgroup-activity.component.ts @@ -0,0 +1,156 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {NavigationLink} from "../../navigation-link-list/navigation-link-list.component"; +import * as moment from "moment"; +import {ChangeContext, LabelType, Options} from "ngx-slider-v2"; +import { + ChartComponent, + ApexAxisChartSeries, + ApexChart, + ApexXAxis, + ApexTitleSubtitle, ChartType +} from "ng-apexcharts"; +import {timeInterval} from "rxjs"; +import {FormControl} from "@angular/forms"; +import {HistoryService, TaskgroupPathInfo, TaskgroupService} from "../../../api"; + +export type ChartOptions = { + series: ApexAxisChartSeries; + chart: ApexChart; + xaxis: ApexXAxis; + title: ApexTitleSubtitle; +}; +@Component({ + selector: 'app-taskgroup-activity', + templateUrl: './taskgroup-activity.component.html', + styleUrls: ['./taskgroup-activity.component.css'] +}) +export class TaskgroupActivityComponent implements OnInit{ + defaultNavigationLinkPath: NavigationLink[] = [ + { + linkText: 'Dashboard', + routerLink: ['/'] + }, + { + linkText: 'Statistics', + routerLink: [] + }, + { + linkText: 'Taskgroup Activity', + routerLink: ['/statistics/taskgroup-activity'] + } + ]; + + + selectedChartype: string = "bar"; + availableChartTypes: string[] = ["bar", "line", "area"] + selectedTaskgroupPath: TaskgroupPathInfo | undefined + taskgroupPaths: TaskgroupPathInfo[] = [] + sliderControl: FormControl = new FormControl() + dateRange: Date[] = this.createDateRange(); + selectedDateRange: Date[] = this.dateRange; + value: number = this.dateRange[0].getTime(); + options: Options = { + stepsArray: this.dateRange.map((date: Date) => { + return { value: date.getTime() }; + }), + translate: (value: number, label: LabelType): string => { + return new Date(value).toDateString(); + }, + floor: 0, + ceil: this.dateRange.length, + }; + + @ViewChild("chart") chart?: ChartComponent; + public chartOptions: Partial = this.generateChartOptions() + + constructor(private taskgroupService: TaskgroupService, + private historyService: HistoryService) { + } + + ngOnInit() { + this.taskgroupService.taskgroupsPathGet().subscribe({ + next: resp => { + this.taskgroupPaths = resp; + } + }) + } + + + createDateRange(): Date[] { + const dates: Date[] = []; + for (let i: number = 1; i <= 31; i++) { + + dates.push(moment().subtract(30-i, 'd').toDate()); + } + this.sliderControl.setValue([dates[0], dates[dates.length-1]]) + return dates; + } + + createDateRangeBetween(minValue: moment.Moment, maxValue: moment.Moment) { + const dates: Date[] = []; + const numberDays = maxValue.diff(minValue, 'days'); + for(let i=0; i<=numberDays; i++) { + dates.push(minValue.add(i, 'd').toDate()); + } + return dates; + } + + onUserChangeStart(changeContext: ChangeContext) { + + //console.log("onUserChangeStart" + new Date(changeContext.value)); + } + + onUserChangeStop(changeContext: ChangeContext) { + //console.log("onUserChangeStop" + new Date(changeContext.highValue!)) + } + + onUserChange(changeContext: ChangeContext) { + const dateRange = this.createDateRangeBetween(moment(changeContext.value), moment(changeContext.highValue!)) + this.chartOptions = this.generateChartOptions() + } + + generateChartOptions(): Partial { + + return { + series: this.generateSeries(), + chart: { + height: 350, + type: this.selectedChartype as ChartType, + stacked: true + }, + title: { + text: "" + }, + xaxis: { + categories: this.selectedDateRange.map(date => date.toDateString()) + } + }; + } + + generateSeries() : ApexAxisChartSeries { + const series: ApexAxisChartSeries = [] + this.selectedTaskgroupPath?.directChildren.forEach(taskgroup => { + this.historyService.statisticsTaskgroupActivityTaskgroupIDStartingDateEndingDateIncludeSubTaskgroupsGet( + taskgroup.taskgroupID, + moment(this.selectedDateRange[0]).format("YYYY-MM-DD"), + moment(this.selectedDateRange[this.selectedDateRange.length-1]).format("YYYY-MM-DD"), true + ).subscribe({ + next: resp => { + series.push( + { + name: taskgroup.taskgroupName, + data: resp.map(dailyActivityInfo => dailyActivityInfo.activeMinutes) + } + ) + } + }) + + }) + return series; + + } + + updateSerieSelection() { + this.chartOptions = this.generateChartOptions() + } +} diff --git a/openapi.yaml b/openapi.yaml index 13559c7..6c8bf09 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -883,6 +883,23 @@ paths: schema: type: object $ref: "#/components/schemas/SimpleStatusResponse" + /taskgroups/path: + get: + security: + - API_TOKEN: [] + tags: + - taskgroup + summary: lists all taskgrouppaths + description: lists all taskgroup-paths + responses: + 200: + description: Operation successfull + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskgroupPathInfo' /tasks/all/{scope}/{detailed}: get: security: @@ -1888,6 +1905,63 @@ paths: schema: type: object $ref: "#/components/schemas/SimpleStatusResponse" + /statistics/taskgroup-activity/{taskgroupID}/{startingDate}/{endingDate}/{includeSubTaskgroups}: + get: + security: + - API_TOKEN: [] + tags: + - history + parameters: + - name: taskgroupID + in: path + description: internal id of taskgroup + required: true + schema: + type: number + example: 1 + - name: startingDate + in: path + description: starting date + required: true + schema: + type: string + format: date + - name: endingDate + in: path + description: starting date + required: true + schema: + type: string + format: date + - name: includeSubTaskgroups + in: path + description: determines whether to include subtaskgroups or not + required: true + schema: + type: boolean + example: false + responses: + 200: + description: Operation successfull + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskgroupActivityInfo' + 403: + description: No permission + content: + application/json: + schema: + $ref: '#/components/schemas/SimpleStatusResponse' + 404: + description: Schedule not found + content: + application/json: + schema: + $ref: '#/components/schemas/SimpleStatusResponse' + components: securitySchemes: @@ -2529,4 +2603,30 @@ components: format: date scheduleStopTime: type: string - format: date \ No newline at end of file + format: date + TaskgroupPathInfo: + required: + - taskgroupPath + - directChildren + additionalProperties: false + properties: + taskgroupPath: + type: string + description: TaskgroupPath + directChildren: + type: array + items: + $ref: '#/components/schemas/TaskgroupEntityInfo' + TaskgroupActivityInfo: + required: + - date + - activeMinutes + additionalProperties: false + properties: + date: + type: string + format: date + activeMinutes: + type: number + description: Number of minutes the task was active + example: 122 \ No newline at end of file