kimai-plugin-heatmap/.planning/phase-2/02-RESEARCH.md

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]

  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

// 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 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:

$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');
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 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)