26 KiB
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]
WidgetInterfacehas#[AutoconfigureTag]-- any implementing class auto-gets the tagWidgetCompilerPassfinds all services tagged withWidgetInterface::class- Each is registered with
WidgetService::registerWidget() DashboardControllercallsWidgetService::getAllWidgets(), filters by permissionsrender_widget()Twig function callswidget->getData()then renderswidget->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 byWidgetExtensionbefore renderinggetTimezone()-- returns user's timezonegetTemplateName()-- default auto-generates from class name, but we override itgetOptions(),setOption()-- option management- Date helper methods (
createDate(), etc.)
Key Methods to Implement
// 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:
// 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:
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
beginwhen a timesheet entry is created - Already timezone-correct (no need for manual timezone conversion in queries)
- Indexed:
IDX_4F60C6B1BDF467148D93D649on(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:
$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.
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):
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
// 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
// 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)
{# 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
# Resources/config/routes.yaml
heatmap_controllers:
resource: '../../Controller/'
type: attribute
PHPUnit Test (Unit, mocked repository)
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 testsTests/Service/HeatmapServiceTest.php-- covers TEST-01Tests/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
-
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 thedate_tzcolumn 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.
-
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.phpfrom the plugin root when symlinked intovar/plugins/.
- What we know: Plugin lives in
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 handlingsrc/Widget/Type/AbstractWidgetType.php-- extended base classsrc/Widget/WidgetService.php-- widget registrysrc/Widget/Type/DailyWorkingTimeChart.php-- reference widget using TimesheetRepositorysrc/DependencyInjection/Compiler/WidgetCompilerPass.php-- auto-registrationsrc/Twig/Runtime/WidgetExtension.php-- render_widget() implementationsrc/Controller/DashboardController.php-- dashboard rendering, DashboardEventsrc/Event/DashboardEvent.php-- default widget list managementsrc/Entity/Timesheet.php-- entity structure,date_tzcolumnsrc/Repository/TimesheetRepository.php-- query patternssrc/Timesheet/TimesheetStatisticService.php-- daily aggregation referencesrc/Timesheet/DateTimeFactory.php-- timezone-aware date creationsrc/API/BaseApiController.php-- API controller pattern (FOS Rest)src/Plugin/PluginInterface.php,PluginManager.php,PluginMetadata.php-- plugin systemsrc/Kernel.php-- plugin loading, route auto-importtemplates/dashboard/index.html.twig-- dashboard renderingtemplates/widget/-- existing widget templatesphpunit.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_tzcolumn 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)