581 lines
26 KiB
Markdown
581 lines
26 KiB
Markdown
# Phase 2: Plugin Scaffold + Data Layer - Research
|
|
|
|
**Researched:** 2026-04-08
|
|
**Domain:** Kimai 2.52.0 widget system, data aggregation, plugin API controllers
|
|
**Confidence:** HIGH
|
|
|
|
## Summary
|
|
|
|
The Kimai widget system uses a clean service-tag pattern: any class implementing `WidgetInterface` (which carries `#[AutoconfigureTag]`) is automatically discovered by `WidgetCompilerPass` and registered with `WidgetService`. The dashboard renders widgets via a `render_widget()` Twig function that calls `getData()` on the widget and renders its template. Plugin templates live in `Resources/views/` and are accessible via the `@KimaiHeatmapBundle/` Twig namespace.
|
|
|
|
For data aggregation, Kimai already has `TimesheetStatisticService::getDailyStatistics()` which queries the `t.date` column (the `date_tz` field -- a `DATE_IMMUTABLE` column that stores the date in the user's timezone, NOT in UTC). This means timezone-correct day grouping is built-in. Our aggregation service should query `t.date` the same way, grouping by day and summing duration + counting entries.
|
|
|
|
The API endpoint should be a standard Symfony controller returning `JsonResponse` -- we do NOT need FOS Rest Bundle (that's for Kimai's own API). Plugin routes are auto-loaded from `Resources/config/routes.yaml` (or `.xml`).
|
|
|
|
**Primary recommendation:** Extend `AbstractWidget` for the heatmap widget, create a dedicated `HeatmapService` that queries `t.date` for daily aggregation, and add a simple controller with `#[Route]` attributes returning `JsonResponse` for the heatmap data endpoint.
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|------------------|
|
|
| PLUG-01 | Plugin is a valid Symfony bundle discoverable by Kimai | Already done -- `KimaiHeatmapBundle` exists with `PluginInterface`, `composer.json` has `extra.kimai` |
|
|
| PLUG-02 | Plugin registers a dashboard widget visible on the Kimai dashboard | Extend `AbstractWidget`, implement `WidgetInterface` methods, widget auto-registers via `#[AutoconfigureTag]` on the interface |
|
|
| PLUG-03 | Plugin ships a backend API endpoint returning aggregated daily time data | Controller with `#[Route]` in `Resources/config/routes.yaml`, query `t.date` column for timezone-correct grouping |
|
|
| TEST-01 | PHPUnit tests for the data aggregation service | Unit test the service with mocked `TimesheetRepository`, test timezone grouping logic |
|
|
| TEST-02 | PHPUnit tests for the API endpoint | Functional test the controller, verify JSON response format |
|
|
</phase_requirements>
|
|
|
|
## Standard Stack
|
|
|
|
### Core (from Kimai 2.52.0 local checkout)
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| PHP | 8.1-8.5 | Runtime | Kimai composer.json `"php": "8.1.*\|\|8.2.*\|\|8.3.*\|\|8.4.*\|\|8.5.*"` [VERIFIED: local composer.json] |
|
|
| Symfony | 6.x | Framework | Kimai requires `symfony/*: ^6.0` [VERIFIED: local composer.json] |
|
|
| Doctrine ORM | (bundled) | Database | Kimai uses Doctrine ORM with entity repositories [VERIFIED: local source] |
|
|
| PHPUnit | ^10.0 | Testing | Kimai's `require-dev` has `phpunit/phpunit: ^10.0` [VERIFIED: local composer.json] |
|
|
|
|
### Supporting
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| DAMA DoctrineTestBundle | (bundled) | Test DB transactions | Kimai's phpunit.xml.dist uses it as PHPUnit extension [VERIFIED: local phpunit.xml.dist] |
|
|
|
|
**No additional PHP packages needed.** The plugin uses Kimai's own dependencies (Doctrine, Symfony HttpFoundation for JsonResponse, etc.).
|
|
|
|
## Architecture Patterns
|
|
|
|
### Widget Registration Flow
|
|
[VERIFIED: local Kimai 2.52.0 source]
|
|
|
|
1. `WidgetInterface` has `#[AutoconfigureTag]` -- any implementing class auto-gets the tag
|
|
2. `WidgetCompilerPass` finds all services tagged with `WidgetInterface::class`
|
|
3. Each is registered with `WidgetService::registerWidget()`
|
|
4. `DashboardController` calls `WidgetService::getAllWidgets()`, filters by permissions
|
|
5. `render_widget()` Twig function calls `widget->getData()` then renders `widget->getTemplateName()`
|
|
|
|
### Widget Class Hierarchy
|
|
[VERIFIED: local source]
|
|
|
|
```
|
|
WidgetInterface (interface, #[AutoconfigureTag])
|
|
-> AbstractWidget (base class, implements most methods)
|
|
-> AbstractWidgetType (adds id/title/permissions setters)
|
|
-> AbstractCounterDuration, etc.
|
|
-> DailyWorkingTimeChart (extends AbstractWidget directly)
|
|
```
|
|
|
|
For our heatmap, extend `AbstractWidget` directly (like `DailyWorkingTimeChart` does). This gives us:
|
|
- `setUser()`/`getUser()` -- injected by `WidgetExtension` before rendering
|
|
- `getTimezone()` -- returns user's timezone
|
|
- `getTemplateName()` -- default auto-generates from class name, but we override it
|
|
- `getOptions()`, `setOption()` -- option management
|
|
- Date helper methods (`createDate()`, etc.)
|
|
|
|
### Key Methods to Implement
|
|
|
|
```php
|
|
// Required by WidgetInterface:
|
|
public function getId(): string; // Unique widget ID, e.g. 'HeatmapWidget'
|
|
public function getTitle(): string; // Translation key or plain text
|
|
public function getData(array $options = []): mixed; // Widget data (passed to template as 'data')
|
|
public function getTemplateName(): string; // Twig template path
|
|
|
|
// Override from AbstractWidget for sizing:
|
|
public function getWidth(): int; // WidgetInterface::WIDTH_FULL (4) for full width
|
|
public function getHeight(): int; // WidgetInterface::HEIGHT_MEDIUM (3) or LARGE (5)
|
|
|
|
// Permission check:
|
|
public function getPermissions(): array; // ['view_own_timesheet']
|
|
```
|
|
|
|
### Template Rendering
|
|
[VERIFIED: local source `WidgetExtension.php`]
|
|
|
|
When `render_widget($widget)` is called in dashboard template:
|
|
```php
|
|
// In WidgetExtension::renderWidget():
|
|
$widget->setUser($currentUser);
|
|
$options = $widget->getOptions($options);
|
|
return $environment->render($widget->getTemplateName(), [
|
|
'data' => $widget->getData($options),
|
|
'options' => $options,
|
|
'title' => $widget->getTitle(),
|
|
'widget' => $widget,
|
|
]);
|
|
```
|
|
|
|
### Plugin Template Location
|
|
[VERIFIED: Symfony Bundle conventions]
|
|
|
|
Templates go in `Resources/views/`. For a widget returning `@KimaiHeatmapBundle/widget/heatmap.html.twig`:
|
|
```
|
|
Resources/
|
|
views/
|
|
widget/
|
|
heatmap.html.twig
|
|
```
|
|
|
|
### Plugin Route Loading
|
|
[VERIFIED: local Kernel.php lines 181-187]
|
|
|
|
Kimai's Kernel auto-loads plugin routes from:
|
|
- `{plugin}/Resources/config/routes.{yaml,xml}` (preferred)
|
|
- `{plugin}/config/routes.{yaml,xml}` (alternative)
|
|
|
|
### API Endpoint Pattern
|
|
[VERIFIED: local source]
|
|
|
|
Kimai's own API uses FOS Rest Bundle, but for a plugin endpoint serving JSON data to our own widget, a simple Symfony controller with `JsonResponse` is cleaner and has no extra dependencies:
|
|
|
|
```php
|
|
namespace KimaiPlugin\KimaiHeatmapBundle\Controller;
|
|
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
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(HeatmapService $service): JsonResponse
|
|
{
|
|
$user = $this->getUser();
|
|
$data = $service->getDailyAggregation($user);
|
|
return new JsonResponse($data);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Aggregation: The `date_tz` Column
|
|
[VERIFIED: local Entity/Timesheet.php and TimesheetStatisticService.php]
|
|
|
|
**Critical finding:** Kimai stores a `date_tz` column (mapped as `t.date` in DQL) which is a `DATE_IMMUTABLE` that reflects the date in the user's timezone. This column is:
|
|
- Set automatically from `begin` when a timesheet entry is created
|
|
- Already timezone-correct (no need for manual timezone conversion in queries)
|
|
- Indexed: `IDX_4F60C6B1BDF467148D93D649` on `(date_tz, user)`
|
|
|
|
Kimai's `TimesheetStatisticService::getDailyStatistics()` queries on `t.date` (not `t.begin`), groups by `DAY(t.date)`, `MONTH(t.date)`, `YEAR(t.date)`, and this is the correct pattern for timezone-aware daily aggregation.
|
|
|
|
Our `HeatmapService` should use the same approach:
|
|
|
|
```php
|
|
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
|
$qb
|
|
->select('COALESCE(SUM(t.duration), 0) as duration')
|
|
->addSelect('COUNT(t.id) as count')
|
|
->addSelect('DATE(t.date) as date')
|
|
->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('date');
|
|
```
|
|
|
|
### Recommended Project Structure
|
|
```
|
|
KimaiHeatmapBundle/
|
|
KimaiHeatmapBundle.php # Bundle class (exists)
|
|
composer.json # Plugin metadata (exists)
|
|
DependencyInjection/
|
|
KimaiHeatmapExtension.php # DI extension (exists)
|
|
Resources/
|
|
config/
|
|
services.yaml # Service definitions (exists, needs widget + service)
|
|
routes.yaml # Route imports (NEW)
|
|
views/
|
|
widget/
|
|
heatmap.html.twig # Widget template (NEW)
|
|
Controller/
|
|
HeatmapController.php # API endpoint (NEW)
|
|
Service/
|
|
HeatmapService.php # Data aggregation (NEW)
|
|
Widget/
|
|
HeatmapWidget.php # Dashboard widget (NEW)
|
|
EventSubscriber/
|
|
DashboardSubscriber.php # Add widget to default dashboard (NEW, optional)
|
|
Tests/
|
|
Service/
|
|
HeatmapServiceTest.php # TEST-01
|
|
Controller/
|
|
HeatmapControllerTest.php # TEST-02
|
|
```
|
|
|
|
### Service Registration
|
|
|
|
The `services.yaml` needs to register the widget and service. The widget must be tagged so `WidgetCompilerPass` finds it. Since `WidgetInterface` has `#[AutoconfigureTag]`, Symfony's autoconfigure will tag it automatically IF autowiring is enabled 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']
|
|
```
|
|
|
|
The key insight: with `autoconfigure: true` and proper PSR-4 resource scanning, any class implementing `WidgetInterface` will automatically get the `App\Widget\WidgetInterface` tag, making it discoverable by `WidgetCompilerPass`.
|
|
|
|
### DashboardEvent Listener (Optional)
|
|
|
|
To add the widget to the DEFAULT dashboard (for users who haven't customized theirs):
|
|
|
|
```php
|
|
use App\Event\DashboardEvent;
|
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
|
|
#[AsEventListener(event: DashboardEvent::class)]
|
|
class DashboardSubscriber
|
|
{
|
|
public function __invoke(DashboardEvent $event): void
|
|
{
|
|
$event->addWidget('HeatmapWidget');
|
|
}
|
|
}
|
|
```
|
|
|
|
Without this, the widget is available but users must manually add it via dashboard edit. For development, adding the event listener is more convenient.
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Timezone-aware day grouping | Custom timezone conversion | Query on `t.date` (the `date_tz` column) | Kimai already stores timezone-correct dates [VERIFIED] |
|
|
| User authentication | Auth middleware | `#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]` | Symfony security handles this [VERIFIED] |
|
|
| Getting current user | Custom user resolution | `$this->getUser()` on controller, `$widget->getUser()` on widget | Both return `App\Entity\User` [VERIFIED] |
|
|
| Widget registration | Manual service tagging | `autoconfigure: true` with `WidgetInterface` implementing class | `#[AutoconfigureTag]` on the interface handles it [VERIFIED] |
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Widget Template Path
|
|
**What goes wrong:** Widget template not found because path doesn't match Symfony bundle conventions.
|
|
**Why it happens:** `AbstractWidget::getTemplateName()` returns `widget/widget-{classname}.html.twig` which resolves to the app's `templates/` directory, not the plugin's.
|
|
**How to avoid:** Override `getTemplateName()` to return `@KimaiHeatmapBundle/widget/heatmap.html.twig` (the Twig namespace for the bundle). Templates must be in `Resources/views/`.
|
|
**Warning signs:** "Unable to find template" errors.
|
|
|
|
### Pitfall 2: Missing `routes.yaml`
|
|
**What goes wrong:** API endpoint returns 404 because routes aren't loaded.
|
|
**Why it happens:** Kimai's Kernel only auto-loads routes from `Resources/config/routes.*` or `config/routes.*`. Controller attributes alone aren't enough -- the route file must import the controller directory.
|
|
**How to avoid:** Create `Resources/config/routes.yaml` that imports the controller directory.
|
|
**Warning signs:** 404 on the API endpoint despite correct route attributes.
|
|
|
|
### Pitfall 3: Running timesheets excluded
|
|
**What goes wrong:** Active/running timesheets (without `end` date) are included in aggregation, causing misleading data.
|
|
**Why it happens:** Forgetting to filter `t.end IS NOT NULL`.
|
|
**How to avoid:** Always add `->andWhere($qb->expr()->isNotNull('t.end'))` as Kimai's own queries do [VERIFIED: DailyWorkingTimeChart, TimesheetStatisticService].
|
|
|
|
### Pitfall 4: Widget ID collision
|
|
**What goes wrong:** Widget ID clashes with another widget, causing one to be overwritten in `WidgetService`.
|
|
**Why it happens:** Using a generic name like "Heatmap".
|
|
**How to avoid:** Use a specific ID like `ActivityHeatmap` or `HeatmapWidget`.
|
|
|
|
### Pitfall 5: Dashboard cache for existing users
|
|
**What goes wrong:** Widget doesn't show up for users who already have a customized dashboard.
|
|
**Why it happens:** The `DashboardEvent` only affects the default config. Users who have already customized their dashboard have a bookmark storing their widget list.
|
|
**How to avoid:** Users need to manually add the widget via Dashboard > Edit, or reset their dashboard. Document this. The `DashboardSubscriber` only helps new users or those who haven't customized.
|
|
|
|
### Pitfall 6: Plugin test isolation
|
|
**What goes wrong:** Plugin tests require full Kimai kernel boot and database.
|
|
**Why it happens:** Kimai's test infrastructure assumes tests run from within the Kimai project.
|
|
**How to avoid:** For TEST-01 (service tests), use pure unit tests with mocked repository -- no kernel needed. For TEST-02 (controller tests), either mock dependencies or set up a functional test bootstrap that boots the Kimai kernel with the plugin loaded.
|
|
**Recommendation:** Start with unit tests (mocked dependencies). Functional/integration tests against a real Kimai instance can come later.
|
|
|
|
## Code Examples
|
|
|
|
### Widget Class
|
|
```php
|
|
// Source: Pattern from local Kimai DailyWorkingTimeChart.php
|
|
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
|
|
|
|
use App\Widget\Type\AbstractWidget;
|
|
use App\Widget\WidgetInterface;
|
|
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
|
|
|
|
final class HeatmapWidget extends AbstractWidget
|
|
{
|
|
public function __construct(private readonly HeatmapService $service)
|
|
{
|
|
}
|
|
|
|
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
|
|
{
|
|
// For now, return null -- Phase 3 will fetch data via JS from the API
|
|
return null;
|
|
}
|
|
|
|
public function getTemplateName(): string
|
|
{
|
|
return '@KimaiHeatmapBundle/widget/heatmap.html.twig';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Data Aggregation Service
|
|
```php
|
|
// Source: Pattern from local TimesheetStatisticService::getDailyStatistics()
|
|
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): 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 date')
|
|
->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('date')
|
|
->orderBy('date', 'ASC')
|
|
;
|
|
|
|
$results = $qb->getQuery()->getResult();
|
|
|
|
return array_map(function (array $row) {
|
|
return [
|
|
'date' => $row['date'],
|
|
'hours' => round((int) $row['duration'] / 3600, 2),
|
|
'count' => (int) $row['count'],
|
|
];
|
|
}, $results);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Widget Template (Placeholder)
|
|
```twig
|
|
{# 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') }}">
|
|
<p>Heatmap will render here (Phase 3)</p>
|
|
</div>
|
|
{% endblock %}
|
|
{% endembed %}
|
|
```
|
|
|
|
### Routes Configuration
|
|
```yaml
|
|
# Resources/config/routes.yaml
|
|
heatmap_controllers:
|
|
resource: '../../Controller/'
|
|
type: attribute
|
|
```
|
|
|
|
### PHPUnit Test (Unit, mocked repository)
|
|
```php
|
|
namespace KimaiPlugin\KimaiHeatmapBundle\Tests\Service;
|
|
|
|
use App\Entity\User;
|
|
use App\Repository\TimesheetRepository;
|
|
use Doctrine\ORM\AbstractQuery;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class HeatmapServiceTest extends TestCase
|
|
{
|
|
public function testGetDailyAggregation(): void
|
|
{
|
|
$mockResults = [
|
|
['date' => '2026-04-01', 'duration' => 7200, 'count' => 3],
|
|
['date' => '2026-04-02', 'duration' => 3600, 'count' => 1],
|
|
];
|
|
|
|
$query = $this->createMock(AbstractQuery::class);
|
|
$query->method('getResult')->willReturn($mockResults);
|
|
|
|
$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 \Doctrine\ORM\Query\Expr());
|
|
$qb->method('getQuery')->willReturn($query);
|
|
|
|
$repo = $this->createMock(TimesheetRepository::class);
|
|
$repo->method('createQueryBuilder')->willReturn($qb);
|
|
|
|
$service = new HeatmapService($repo);
|
|
$user = $this->createMock(User::class);
|
|
$begin = new \DateTimeImmutable('2026-04-01');
|
|
$end = new \DateTimeImmutable('2026-04-30');
|
|
|
|
$result = $service->getDailyAggregation($user, $begin, $end);
|
|
|
|
$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']);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Validation Architecture
|
|
|
|
### Test Framework
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Framework | PHPUnit 10.x (Kimai's bundled version) |
|
|
| Config file | `phpunit.xml` in plugin root (NEW -- Wave 0) |
|
|
| Quick run command | `php vendor/bin/phpunit --configuration Tests/phpunit.xml` |
|
|
| Full suite command | Same (small test count at this phase) |
|
|
|
|
### Phase Requirements -> Test Map
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
|--------|----------|-----------|-------------------|-------------|
|
|
| TEST-01 | HeatmapService aggregates daily data correctly (duration, count, timezone grouping) | unit | `php vendor/bin/phpunit Tests/Service/HeatmapServiceTest.php` | Wave 0 |
|
|
| TEST-02 | API endpoint returns correct JSON format, requires auth | unit | `php vendor/bin/phpunit Tests/Controller/HeatmapControllerTest.php` | Wave 0 |
|
|
| PLUG-02 | Widget registers and renders on dashboard | manual | Boot Kimai, check dashboard | manual-only: requires running Kimai instance |
|
|
|
|
### Wave 0 Gaps
|
|
- [ ] `Tests/phpunit.xml` -- PHPUnit config for plugin standalone tests
|
|
- [ ] `Tests/Service/HeatmapServiceTest.php` -- covers TEST-01
|
|
- [ ] `Tests/Controller/HeatmapControllerTest.php` -- covers TEST-02
|
|
- [ ] PHPUnit autoload bootstrap pointing to Kimai's vendor autoloader
|
|
|
|
**Note on test bootstrapping:** The plugin lives inside Kimai's `var/plugins/` directory (symlinked). Tests need access to Kimai's autoloader. The `phpunit.xml` should set `bootstrap` to the Kimai `vendor/autoload.php`. For unit tests with mocked dependencies, this is sufficient. Full kernel-boot integration tests would need Kimai's test bootstrap, which is more complex and not needed for Phase 2.
|
|
|
|
### Sampling Rate
|
|
- **Per task commit:** `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml`
|
|
- **Per wave merge:** Same
|
|
- **Phase gate:** All PHPUnit tests green
|
|
|
|
## Security Domain
|
|
|
|
### Applicable ASVS Categories
|
|
|
|
| ASVS Category | Applies | Standard Control |
|
|
|---------------|---------|-----------------|
|
|
| V2 Authentication | yes | Symfony `#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]` -- same as Kimai core |
|
|
| V3 Session Management | no | Handled by Kimai core |
|
|
| V4 Access Control | yes | `#[IsGranted('view_own_timesheet')]` permission check -- same as Kimai widgets |
|
|
| V5 Input Validation | yes | Date parameters validated/typed, no raw user input in queries |
|
|
| V6 Cryptography | no | No crypto operations |
|
|
|
|
### Known Threat Patterns
|
|
|
|
| Pattern | STRIDE | Standard Mitigation |
|
|
|---------|--------|---------------------|
|
|
| SQL injection via date params | Tampering | Doctrine parameterized queries (`:begin`, `:end`) -- same as Kimai core |
|
|
| Accessing other users' data | Information Disclosure | Query always filters by `$this->getUser()` -- never accept user ID from request |
|
|
| CSRF on data endpoint | Tampering | GET-only endpoint, read-only data |
|
|
|
|
## Assumptions Log
|
|
|
|
| # | Claim | Section | Risk if Wrong |
|
|
|---|-------|---------|---------------|
|
|
| A1 | `@KimaiHeatmapBundle/` Twig namespace resolves `Resources/views/` in the plugin bundle | Architecture Patterns | Template not found -- would need to register path manually |
|
|
| A2 | `autoconfigure: true` in plugin's `services.yaml` causes `WidgetInterface` auto-tagging | Architecture Patterns | Widget not discovered -- would need explicit tag in services.yaml |
|
|
| A3 | `@theme/embeds/card.html.twig` is available to plugin templates | Code Examples | Template inheritance fails -- would need different base template |
|
|
| A4 | Doctrine DQL `DATE()` function works with SQLite (dev) | Data Aggregation | Query fails in dev -- would need raw SQL or different function |
|
|
|
|
**A4 is notable:** Kimai uses MySQL/MariaDB in production, but the dev environment setup from Phase 1 may use SQLite. The `DATE()` DQL function's behavior on SQLite should be verified during implementation.
|
|
|
|
## Open Questions
|
|
|
|
1. **SQLite compatibility in dev**
|
|
- What we know: Kimai's production uses MySQL/MariaDB. Phase 1 dev environment may use SQLite.
|
|
- What's unclear: Whether Doctrine's `DATE()` DQL function works on SQLite, and whether the `date_tz` column type is correctly handled.
|
|
- Recommendation: Check the dev database type during implementation. If SQLite, test the query works. If not, may need to adjust or use MySQL in dev.
|
|
|
|
2. **Test bootstrap path**
|
|
- What we know: Plugin lives in `var/plugins/KimaiHeatmapBundle` (symlinked from project root). Tests need Kimai's autoloader.
|
|
- What's unclear: Exact relative path from plugin test config to Kimai's vendor autoloader.
|
|
- Recommendation: Resolve during test setup. The path is `../../vendor/autoload.php` from the plugin root when symlinked into `var/plugins/`.
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- Local Kimai 2.52.0 source at `/home/toph/code/toph/kimai-heatmap/dev/kimai/` -- ALL architecture claims verified against actual source code
|
|
- Files examined:
|
|
- `src/Widget/WidgetInterface.php` -- interface definition, `#[AutoconfigureTag]`
|
|
- `src/Widget/Type/AbstractWidget.php` -- base class, template name convention, timezone handling
|
|
- `src/Widget/Type/AbstractWidgetType.php` -- extended base class
|
|
- `src/Widget/WidgetService.php` -- widget registry
|
|
- `src/Widget/Type/DailyWorkingTimeChart.php` -- reference widget using TimesheetRepository
|
|
- `src/DependencyInjection/Compiler/WidgetCompilerPass.php` -- auto-registration
|
|
- `src/Twig/Runtime/WidgetExtension.php` -- render_widget() implementation
|
|
- `src/Controller/DashboardController.php` -- dashboard rendering, DashboardEvent
|
|
- `src/Event/DashboardEvent.php` -- default widget list management
|
|
- `src/Entity/Timesheet.php` -- entity structure, `date_tz` column
|
|
- `src/Repository/TimesheetRepository.php` -- query patterns
|
|
- `src/Timesheet/TimesheetStatisticService.php` -- daily aggregation reference
|
|
- `src/Timesheet/DateTimeFactory.php` -- timezone-aware date creation
|
|
- `src/API/BaseApiController.php` -- API controller pattern (FOS Rest)
|
|
- `src/Plugin/PluginInterface.php`, `PluginManager.php`, `PluginMetadata.php` -- plugin system
|
|
- `src/Kernel.php` -- plugin loading, route auto-import
|
|
- `templates/dashboard/index.html.twig` -- dashboard rendering
|
|
- `templates/widget/` -- existing widget templates
|
|
- `phpunit.xml.dist` -- test configuration
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- Symfony Bundle template namespace conventions (standard Symfony 6.x behavior) [ASSUMED for A1, A3]
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH -- verified against local Kimai source and composer.json
|
|
- Architecture: HIGH -- all widget, route, and DI patterns verified in source
|
|
- Data layer: HIGH -- `date_tz` column and query patterns verified in entity and service code
|
|
- Pitfalls: HIGH -- derived from actual source code behavior
|
|
- Testing: MEDIUM -- test bootstrap path needs verification during implementation
|
|
|
|
**Research date:** 2026-04-08
|
|
**Valid until:** 2026-05-08 (stable -- Kimai 2.52.0 is a fixed target)
|