From 2ccc096f43d6cab605e566ac81efeb9c44b9f055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Wed, 8 Apr 2026 12:43:43 +0200 Subject: [PATCH] docs(phase-2): research widget system, data layer, and API patterns --- .planning/phase-2/02-RESEARCH.md | 581 +++++++++++++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 .planning/phase-2/02-RESEARCH.md diff --git a/.planning/phase-2/02-RESEARCH.md b/.planning/phase-2/02-RESEARCH.md new file mode 100644 index 0000000..3e8974f --- /dev/null +++ b/.planning/phase-2/02-RESEARCH.md @@ -0,0 +1,581 @@ +# 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 + +| 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 | + + +## 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 + */ + 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 %} +
+

Heatmap will render here (Phase 3)

+
+ {% 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)