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

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

  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:

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)

  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