15 KiB
Phase 2: Plugin Scaffold + Data Layer — Plan
Goal: Plugin shows a widget on the dashboard and serves aggregated daily time data via API. Requirements: PLUG-01 (done), PLUG-02, PLUG-03, TEST-01, TEST-02 Research: phase-2/02-RESEARCH.md
Success Criteria
- Kimai discovers the plugin and lists it in the plugin admin page (DONE — Phase 1)
- A widget placeholder appears on the Kimai dashboard
- The API endpoint returns JSON with per-day aggregated hours and entry counts, correctly grouped by the user's timezone
- PHPUnit tests pass for the data aggregation service and API endpoint
Key Decisions from Research
- Extend
AbstractWidget(notAbstractWidgetType) — same pattern asDailyWorkingTimeChart - Query
t.date(thedate_tzcolumn) for timezone-correct day grouping — no manual conversion needed - Simple
JsonResponsecontroller, NOT FOS Rest Bundle — cleaner for plugin - Unit tests with mocked repository for Phase 2 — integration tests deferred
DashboardSubscriberneeded for widget to appear on default dashboard
Waves
Wave 1: Data Layer + Tests (autonomous)
Plan 02-01: HeatmapService, PHPUnit Tests, and API Endpoint
Objective: Build the data aggregation service with tests, then the API controller.
Task 1: Update services.yaml for autowiring
Update Resources/config/services.yaml to enable autowiring and autoconfigure for the plugin namespace:
services:
_defaults:
autowire: true
autoconfigure: true
KimaiPlugin\KimaiHeatmapBundle\:
resource: '../../{Controller,Service,Widget,EventSubscriber}/'
KimaiPlugin\KimaiHeatmapBundle\KimaiHeatmapBundle:
tags: ['App\Plugin\PluginInterface']
Task 2: Create HeatmapService
Create Service/HeatmapService.php:
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
final 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);
}
}
Task 3: Create PHPUnit config and tests
Create Tests/phpunit.xml:
<?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>
Create Tests/bootstrap.php:
<?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.');
Create Tests/Service/HeatmapServiceTest.php:
<?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);
}
}
Task 4: Create API controller
Create Controller/HeatmapController.php:
<?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'),
],
]);
}
}
Create Resources/config/routes.yaml:
heatmap_controllers:
resource: '../../Controller/'
type: attribute
Task 5: Create controller test
Create Tests/Controller/HeatmapControllerTest.php:
<?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']);
}
}
Commit: feat: add HeatmapService, API controller, and PHPUnit tests
Verification:
php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml— all tests pass
Wave 2: Dashboard Widget (checkpoint for visual verification)
Plan 02-02: HeatmapWidget + Dashboard Integration
Objective: Widget placeholder appears on the Kimai dashboard.
Task 1: Create HeatmapWidget
Create Widget/HeatmapWidget.php:
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
use App\Widget\Type\AbstractWidget;
use App\Widget\WidgetInterface;
final class HeatmapWidget extends AbstractWidget
{
public function getId(): string
{
return 'HeatmapWidget';
}
public function getTitle(): string
{
return 'Activity Heatmap';
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_FULL;
}
public function getHeight(): int
{
return WidgetInterface::HEIGHT_LARGE;
}
public function getPermissions(): array
{
return ['view_own_timesheet'];
}
public function getData(array $options = []): mixed
{
return null;
}
public function getTemplateName(): string
{
return '@KimaiHeatmapBundle/widget/heatmap.html.twig';
}
}
Task 2: Create widget template
Create Resources/views/widget/heatmap.html.twig:
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% block box_title %}
{{ title }}
{% endblock %}
{% block box_body %}
<div id="heatmap-container" data-url="{{ path('heatmap_data') }}" style="min-height: 150px; padding: 1rem;">
<p style="color: var(--bs-secondary);">Heatmap visualization coming in Phase 3</p>
</div>
{% endblock %}
{% endembed %}
Task 3: Create DashboardSubscriber
Create EventSubscriber/DashboardSubscriber.php:
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\EventSubscriber;
use App\Event\DashboardEvent;
use App\Widget\WidgetService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class DashboardSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly WidgetService $widgetService)
{
}
public static function getSubscribedEvents(): array
{
return [
DashboardEvent::class => ['onDashboard', 100],
];
}
public function onDashboard(DashboardEvent $event): void
{
$widget = $this->widgetService->getWidget('HeatmapWidget');
if ($widget !== null) {
$event->addWidget($widget);
}
}
}
Commit: feat: add dashboard widget with placeholder template
Task 4: Verify (CHECKPOINT — requires manual verification)
- Clear Kimai cache:
cd dev/kimai && bin/console cache:clear - Start dev stack:
process-compose -f dev/process-compose.yaml -p 0 up - Open browser:
http://127.0.0.1:8010 - Login:
susan_super/password
Verification checklist:
- "Activity Heatmap" widget appears on the dashboard
- Widget shows placeholder text "Heatmap visualization coming in Phase 3"
- Visiting
/heatmap/datareturns JSON withdaysarray andrangeobject - JSON
daysentries havedate,hours,countfields - PHPUnit tests pass:
php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml
Requirement Coverage
| Requirement | Plan | Verified By |
|---|---|---|
| PLUG-01 | Phase 1 | Already done |
| PLUG-02 | 02-02 | Widget visible on dashboard |
| PLUG-03 | 02-01 | API returns JSON; PHPUnit test |
| TEST-01 | 02-01 | HeatmapServiceTest passes |
| TEST-02 | 02-01 | HeatmapControllerTest passes |
Risks
| Risk | Mitigation |
|---|---|
@theme/embeds/card.html.twig doesn't exist |
Fall back to plain <div> wrapper |
DATE() DQL function fails on MariaDB |
Phase 1 uses MariaDB — should work natively |
| Widget not appearing despite registration | Check WidgetService debug, ensure autoconfigure is true |
| Test bootstrap can't find Kimai autoloader | Multiple path fallbacks in bootstrap.php |
Plan created: 2026-04-08