# 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 */ 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 . ``` Create `Tests/bootstrap.php`: ```php '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 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 '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

Heatmap visualization coming in Phase 3

{% endblock %} {% endembed %} ``` **Task 3: Create DashboardSubscriber** Create `EventSubscriber/DashboardSubscriber.php`: ```php ['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 `
` 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*