From 7060cfc151c4fb7c1936eac36b7225ed1ecc3e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Wed, 8 Apr 2026 12:49:54 +0200 Subject: [PATCH] feat: add HeatmapService, API controller, and PHPUnit tests Co-Authored-By: Claude Opus 4.6 --- Controller/HeatmapController.php | 36 +++++++++ Resources/config/routes.yaml | 3 + Resources/config/services.yaml | 7 ++ Service/HeatmapService.php | 50 +++++++++++++ Tests/Controller/HeatmapControllerTest.php | 46 ++++++++++++ Tests/Service/HeatmapServiceTest.php | 86 ++++++++++++++++++++++ Tests/bootstrap.php | 15 ++++ Tests/phpunit.xml | 11 +++ 8 files changed, 254 insertions(+) create mode 100644 Controller/HeatmapController.php create mode 100644 Resources/config/routes.yaml create mode 100644 Service/HeatmapService.php create mode 100644 Tests/Controller/HeatmapControllerTest.php create mode 100644 Tests/Service/HeatmapServiceTest.php create mode 100644 Tests/bootstrap.php create mode 100644 Tests/phpunit.xml diff --git a/Controller/HeatmapController.php b/Controller/HeatmapController.php new file mode 100644 index 0000000..23de4b3 --- /dev/null +++ b/Controller/HeatmapController.php @@ -0,0 +1,36 @@ +getUser(); + $end = new \DateTimeImmutable('today'); + $begin = $end->modify('-1 year'); + + $projectId = $request->query->getInt('project') ?: null; + + $days = $service->getDailyAggregation($user, $begin, $end, $projectId); + + return new JsonResponse([ + 'days' => $days, + 'range' => [ + 'begin' => $begin->format('Y-m-d'), + 'end' => $end->format('Y-m-d'), + ], + ]); + } +} diff --git a/Resources/config/routes.yaml b/Resources/config/routes.yaml new file mode 100644 index 0000000..ed15f3e --- /dev/null +++ b/Resources/config/routes.yaml @@ -0,0 +1,3 @@ +heatmap_controllers: + resource: '../../Controller/' + type: attribute diff --git a/Resources/config/services.yaml b/Resources/config/services.yaml index 123293a..c7e960e 100644 --- a/Resources/config/services.yaml +++ b/Resources/config/services.yaml @@ -1,3 +1,10 @@ services: + _defaults: + autowire: true + autoconfigure: true + + KimaiPlugin\KimaiHeatmapBundle\: + resource: '../../{Controller,Service,Widget,EventSubscriber}/' + KimaiPlugin\KimaiHeatmapBundle\KimaiHeatmapBundle: tags: ['App\Plugin\PluginInterface'] diff --git a/Service/HeatmapService.php b/Service/HeatmapService.php new file mode 100644 index 0000000..d70f707 --- /dev/null +++ b/Service/HeatmapService.php @@ -0,0 +1,50 @@ + + */ + public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null): array + { + $qb = $this->repository->createQueryBuilder('t'); + + $qb + ->select('COALESCE(SUM(t.duration), 0) as duration') + ->addSelect('COUNT(t.id) as count') + ->addSelect('DATE(t.date) as day') + ->andWhere($qb->expr()->between('t.date', ':begin', ':end')) + ->andWhere($qb->expr()->eq('t.user', ':user')) + ->andWhere($qb->expr()->isNotNull('t.end')) + ->setParameter('begin', $begin->format('Y-m-d')) + ->setParameter('end', $end->format('Y-m-d')) + ->setParameter('user', $user) + ->groupBy('day') + ->orderBy('day', 'ASC') + ; + + if ($projectId !== null) { + $qb->andWhere($qb->expr()->eq('t.project', ':project')) + ->setParameter('project', $projectId); + } + + $results = $qb->getQuery()->getResult(); + + return array_map(function (array $row) { + return [ + 'date' => $row['day'], + 'hours' => round((int) $row['duration'] / 3600, 2), + 'count' => (int) $row['count'], + ]; + }, $results); + } +} diff --git a/Tests/Controller/HeatmapControllerTest.php b/Tests/Controller/HeatmapControllerTest.php new file mode 100644 index 0000000..f340c24 --- /dev/null +++ b/Tests/Controller/HeatmapControllerTest.php @@ -0,0 +1,46 @@ + '2026-04-01', 'hours' => 2.0, 'count' => 3], + ]; + + $service = $this->createMock(HeatmapService::class); + $service->method('getDailyAggregation')->willReturn($mockDays); + + $controller = new HeatmapController(); + + // Mock user via reflection (AbstractController::getUser is protected) + $user = $this->createMock(User::class); + $container = $this->createMock(\Symfony\Component\DependencyInjection\ContainerInterface::class); + $tokenStorage = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface::class); + $token = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\TokenInterface::class); + $token->method('getUser')->willReturn($user); + $tokenStorage->method('getToken')->willReturn($token); + $container->method('has')->willReturn(true); + $container->method('get')->willReturn($tokenStorage); + $controller->setContainer($container); + + $response = $controller->data(new Request(), $service); + + $this->assertInstanceOf(JsonResponse::class, $response); + $data = json_decode($response->getContent(), true); + $this->assertArrayHasKey('days', $data); + $this->assertArrayHasKey('range', $data); + $this->assertCount(1, $data['days']); + $this->assertArrayHasKey('begin', $data['range']); + $this->assertArrayHasKey('end', $data['range']); + } +} diff --git a/Tests/Service/HeatmapServiceTest.php b/Tests/Service/HeatmapServiceTest.php new file mode 100644 index 0000000..ab8f670 --- /dev/null +++ b/Tests/Service/HeatmapServiceTest.php @@ -0,0 +1,86 @@ + '2026-04-01', 'duration' => 7200, 'count' => 3], + ['day' => '2026-04-02', 'duration' => 3600, 'count' => 1], + ]; + + $service = $this->createServiceWithResults($mockResults); + $result = $service->getDailyAggregation( + $this->createMock(User::class), + new \DateTimeImmutable('2026-04-01'), + new \DateTimeImmutable('2026-04-30') + ); + + $this->assertCount(2, $result); + $this->assertEquals('2026-04-01', $result[0]['date']); + $this->assertEquals(2.0, $result[0]['hours']); + $this->assertEquals(3, $result[0]['count']); + $this->assertEquals('2026-04-02', $result[1]['date']); + $this->assertEquals(1.0, $result[1]['hours']); + $this->assertEquals(1, $result[1]['count']); + } + + public function testGetDailyAggregationReturnsEmptyForNoData(): void + { + $service = $this->createServiceWithResults([]); + $result = $service->getDailyAggregation( + $this->createMock(User::class), + new \DateTimeImmutable('2026-04-01'), + new \DateTimeImmutable('2026-04-30') + ); + + $this->assertCount(0, $result); + } + + public function testHoursRoundedToTwoDecimals(): void + { + $mockResults = [ + ['day' => '2026-04-01', 'duration' => 5431, 'count' => 2], + ]; + + $service = $this->createServiceWithResults($mockResults); + $result = $service->getDailyAggregation( + $this->createMock(User::class), + new \DateTimeImmutable('2026-04-01'), + new \DateTimeImmutable('2026-04-30') + ); + + $this->assertEquals(1.51, $result[0]['hours']); + } + + private function createServiceWithResults(array $results): HeatmapService + { + $query = $this->createMock(AbstractQuery::class); + $query->method('getResult')->willReturn($results); + + $qb = $this->createMock(QueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('addSelect')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('setParameter')->willReturnSelf(); + $qb->method('groupBy')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('expr')->willReturn(new Expr()); + $qb->method('getQuery')->willReturn($query); + + $repo = $this->createMock(TimesheetRepository::class); + $repo->method('createQueryBuilder')->willReturn($qb); + + return new HeatmapService($repo); + } +} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php new file mode 100644 index 0000000..3511f0c --- /dev/null +++ b/Tests/bootstrap.php @@ -0,0 +1,15 @@ + + + + + . + + +