486 lines
15 KiB
Markdown
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*
|