kimai-plugin-heatmap/.planning/milestones/v1.0-phases/phase-2/02-RESEARCH.md
Christopher Mühl 244c7c66fc
chore: archive v1.0 milestone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 23:25:26 +02:00

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)