kimai-plugin-heatmap/.planning/milestones/v1.0-phases/phase-2/PLAN.md
Christopher Mühl 244c7c66fc
chore: archive v1.0 milestone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 23:25:26 +02:00

486 lines
15 KiB
Markdown

# 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
1. Kimai discovers the plugin and lists it in the plugin admin page (DONE — Phase 1)
2. A widget placeholder appears on the Kimai dashboard
3. The API endpoint returns JSON with per-day aggregated hours and entry counts, correctly grouped by the user's timezone
4. PHPUnit tests pass for the data aggregation service and API endpoint
## Key Decisions from Research
- Extend `AbstractWidget` (not `AbstractWidgetType`) — same pattern as `DailyWorkingTimeChart`
- Query `t.date` (the `date_tz` column) for timezone-correct day grouping — no manual conversion needed
- Simple `JsonResponse` controller, NOT FOS Rest Bundle — cleaner for plugin
- Unit tests with mocked repository for Phase 2 — integration tests deferred
- `DashboardSubscriber` needed 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:
```yaml
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
<?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
<?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
<?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
<?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
<?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`:
```yaml
heatmap_controllers:
resource: '../../Controller/'
type: attribute
```
**Task 5: Create controller test**
Create `Tests/Controller/HeatmapControllerTest.php`:
```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
<?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`:
```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
<?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)**
1. Clear Kimai cache: `cd dev/kimai && bin/console cache:clear`
2. Start dev stack: `process-compose -f dev/process-compose.yaml -p 0 up`
3. Open browser: `http://127.0.0.1:8010`
4. 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/data` returns JSON with `days` array and `range` object
- [ ] JSON `days` entries have `date`, `hours`, `count` fields
- [ ] 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*