feat: add HeatmapService, API controller, and PHPUnit tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2ccc096f43
commit
7060cfc151
8 changed files with 254 additions and 0 deletions
36
Controller/HeatmapController.php
Normal file
36
Controller/HeatmapController.php
Normal 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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
3
Resources/config/routes.yaml
Normal file
3
Resources/config/routes.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
heatmap_controllers:
|
||||
resource: '../../Controller/'
|
||||
type: attribute
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
KimaiPlugin\KimaiHeatmapBundle\:
|
||||
resource: '../../{Controller,Service,Widget,EventSubscriber}/'
|
||||
|
||||
KimaiPlugin\KimaiHeatmapBundle\KimaiHeatmapBundle:
|
||||
tags: ['App\Plugin\PluginInterface']
|
||||
|
|
|
|||
50
Service/HeatmapService.php
Normal file
50
Service/HeatmapService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
46
Tests/Controller/HeatmapControllerTest.php
Normal file
46
Tests/Controller/HeatmapControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
86
Tests/Service/HeatmapServiceTest.php
Normal file
86
Tests/Service/HeatmapServiceTest.php
Normal 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
15
Tests/bootstrap.php
Normal 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
11
Tests/phpunit.xml
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue