# 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)