feat: add HeatmapService, API controller, and PHPUnit tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-04-08 12:49:54 +02:00
parent 2ccc096f43
commit 7060cfc151
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
8 changed files with 254 additions and 0 deletions

View file

@ -0,0 +1,36 @@
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Controller;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/heatmap')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
class HeatmapController extends AbstractController
{
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function data(Request $request, HeatmapService $service): JsonResponse
{
$user = $this->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'),
],
]);
}
}

View file

@ -0,0 +1,3 @@
heatmap_controllers:
resource: '../../Controller/'
type: attribute

View file

@ -1,3 +1,10 @@
services:
_defaults:
autowire: true
autoconfigure: true
KimaiPlugin\KimaiHeatmapBundle\:
resource: '../../{Controller,Service,Widget,EventSubscriber}/'
KimaiPlugin\KimaiHeatmapBundle\KimaiHeatmapBundle:
tags: ['App\Plugin\PluginInterface']

View file

@ -0,0 +1,50 @@
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
class HeatmapService
{
public function __construct(private readonly TimesheetRepository $repository)
{
}
/**
* @return array<int, array{date: string, hours: float, count: int}>
*/
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);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Tests\Controller;
use App\Entity\User;
use KimaiPlugin\KimaiHeatmapBundle\Controller\HeatmapController;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class HeatmapControllerTest extends TestCase
{
public function testDataReturnsJsonWithDaysAndRange(): void
{
$mockDays = [
['date' => '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']);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Tests\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use PHPUnit\Framework\TestCase;
class HeatmapServiceTest extends TestCase
{
public function testGetDailyAggregationReturnsFormattedResults(): void
{
$mockResults = [
['day' => '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);
}
}

15
Tests/bootstrap.php Normal file
View file

@ -0,0 +1,15 @@
<?php
// When symlinked into Kimai's var/plugins/, the vendor autoload is two levels up
$autoloadPaths = [
__DIR__ . '/../dev/kimai/vendor/autoload.php', // Running from plugin root
__DIR__ . '/../../../vendor/autoload.php', // Running from inside var/plugins/
];
foreach ($autoloadPaths as $path) {
if (file_exists($path)) {
require_once $path;
return;
}
}
throw new RuntimeException('Cannot find Kimai vendor/autoload.php. Run composer install in dev/kimai/ first.');

11
Tests/phpunit.xml Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="bootstrap.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>.</directory>
</testsuite>
</testsuites>
</phpunit>