17 KiB
Architecture Patterns
Domain: Kimai dashboard widget plugin (Symfony bundle + d3.js) Researched: 2026-04-08 Confidence: MEDIUM (based on training knowledge of Kimai 2.x plugin system; no live doc verification possible)
Recommended Architecture
High-Level Overview
+------------------------------------------+
| Kimai Dashboard (Twig) |
| +------------------------------------+ |
| | HeatmapWidget (registered via | |
| | WidgetInterface + DI tag) | |
| | +------------------------------+ | |
| | | Twig template renders | | |
| | | <div id="heatmap-widget"> | | |
| | | + inline d3.js bundle | | |
| | +------------------------------+ | |
| +------------------------------------+ |
+------------------------------------------+
| ^
| XHR/fetch | JSON
v |
+------------------------------------------+
| HeatmapController (Symfony) |
| GET /api/heatmap?range=&project= |
| - Queries TimesheetRepository |
| - Aggregates hours/counts per day |
| - Returns JSON |
+------------------------------------------+
|
v
+------------------------------------------+
| Kimai Database (timesheet table) |
| - begin, end, duration, project_id, |
| activity_id, user_id |
+------------------------------------------+
Component Boundaries
| Component | Responsibility | Communicates With |
|---|---|---|
KimaiHeatmapBundle |
Bundle class, DI registration | Symfony kernel |
HeatmapWidget |
Implements WidgetInterface, provides widget metadata and Twig rendering |
Dashboard renderer, Twig |
HeatmapController |
API endpoint serving aggregated time data as JSON | Widget frontend (JS), HeatmapService |
HeatmapService |
Data aggregation logic (hours/counts per day) | Kimai's TimesheetRepository / Doctrine |
d3 heatmap module |
Client-side calendar heatmap rendering | HeatmapController API (fetch), DOM |
| Twig template | Widget HTML shell + d3 script inclusion | HeatmapWidget, Encore/Webpack assets |
Kimai Plugin Bundle Structure
Kimai plugins are standard Symfony bundles placed in var/plugins/ (for local install) or loaded via Composer. The canonical directory layout:
KimaiHeatmapBundle/
KimaiHeatmapBundle.php # Bundle class (extends PluginInterface)
DependencyInjection/
KimaiHeatmapExtension.php # Loads services.yaml
Resources/
config/
services.yaml # Service definitions + DI tags
routes.yaml # Plugin routes (controller endpoints)
views/
widget/
heatmap.html.twig # Widget Twig template
public/
heatmap.js # Compiled d3 heatmap script
heatmap.css # Widget styles
Widget/
HeatmapWidget.php # WidgetInterface implementation
Controller/
HeatmapController.php # API controller
Service/
HeatmapService.php # Data aggregation
EventSubscriber/ # Optional: hook into Kimai events
composer.json # Package metadata, autoload config
Bundle Class
// KimaiHeatmapBundle.php
namespace KimaiPlugin\KimaiHeatmapBundle;
use App\Plugin\PluginInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class KimaiHeatmapBundle extends Bundle implements PluginInterface
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
}
}
Key point: Kimai plugins implement App\Plugin\PluginInterface which extends Symfony's BundleInterface. The namespace MUST be KimaiPlugin\<BundleName> for Kimai's plugin loader to discover it.
DependencyInjection Extension
// DependencyInjection/KimaiHeatmapExtension.php
namespace KimaiPlugin\KimaiHeatmapBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class KimaiHeatmapExtension extends Extension implements PrependExtensionInterface
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.yaml');
}
public function prepend(ContainerBuilder $container): void
{
// Prepend route config so Kimai loads our routes
$container->prependExtensionConfig('kimai', [
'plugin' => [
'heatmap' => [
'routes' => '@KimaiHeatmapBundle/Resources/config/routes.yaml',
],
],
]);
}
}
Dashboard Widget System
Kimai's dashboard renders widgets that implement App\Widget\WidgetInterface. Widgets are registered via Symfony DI tags.
Widget Interface
// Widget/HeatmapWidget.php
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
use App\Widget\WidgetInterface;
use App\Widget\Type\AbstractWidgetType;
use App\Repository\TimesheetRepository;
class HeatmapWidget extends AbstractWidgetType
{
public function __construct(
private TimesheetRepository $timesheetRepository
) {}
public function getWidth(): int
{
return WidgetInterface::WIDTH_FULL; // Full-width dashboard widget
}
public function getHeight(): int
{
return WidgetInterface::HEIGHT_LARGE;
}
public function getTitle(): string
{
return 'Activity Heatmap';
}
public function getTemplateName(): string
{
return '@KimaiHeatmap/widget/heatmap.html.twig';
}
public function getId(): string
{
return 'HeatmapWidget';
}
public function getData(array $options = []): mixed
{
// Initial data can be passed to Twig, or widget
// can fetch data via XHR from the API endpoint
return [];
}
public function getPermissions(): array
{
return ['view_own_timesheet'];
}
}
Service Registration (services.yaml)
services:
KimaiPlugin\KimaiHeatmapBundle\Widget\HeatmapWidget:
arguments:
$timesheetRepository: '@App\Repository\TimesheetRepository'
tags:
- { name: 'kimai.widget', priority: 50 }
KimaiPlugin\KimaiHeatmapBundle\Controller\HeatmapController:
tags: ['controller.service_arguments']
KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService:
arguments:
$timesheetRepository: '@App\Repository\TimesheetRepository'
The kimai.widget tag is how the widget gets discovered and rendered on the dashboard. The priority controls ordering (higher = appears earlier).
API Endpoint for Heatmap Data
Controller
// Controller/HeatmapController.php
namespace KimaiPlugin\KimaiHeatmapBundle\Controller;
use App\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/api/heatmap')]
#[IsGranted('view_own_timesheet')]
class HeatmapController extends AbstractController
{
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
public function getData(Request $request, HeatmapService $service): JsonResponse
{
$begin = new \DateTime($request->query->get('begin', '-1 year'));
$end = new \DateTime($request->query->get('end', 'now'));
$projectId = $request->query->getInt('project', 0);
$data = $service->getAggregatedData(
user: $this->getUser(),
begin: $begin,
end: $end,
projectId: $projectId ?: null
);
return new JsonResponse($data);
}
}
Route Registration (routes.yaml)
heatmap_api:
resource: '../../Controller/'
type: annotation # or attribute for PHP 8+
prefix: /api/plugins/heatmap
JSON Response Shape
{
"days": [
{ "date": "2026-01-15", "hours": 7.5, "count": 3 },
{ "date": "2026-01-16", "hours": 4.25, "count": 2 }
],
"range": { "begin": "2025-04-08", "end": "2026-04-08" },
"maxHours": 12.0,
"projects": [
{ "id": 1, "name": "Aitia" },
{ "id": 2, "name": "Aleph Garden" }
]
}
d3.js Integration in Symfony/Twig
Strategy: Standalone JS file loaded in Twig template
Kimai uses Webpack Encore for asset management, but plugins typically ship prebuilt JS/CSS in Resources/public/. Kimai copies these to public/bundles/<bundlename>/ via assets:install.
Recommendation: Ship a self-contained d3 heatmap script rather than integrating with Kimai's Webpack build. This avoids coupling to Kimai's internal build pipeline.
Twig Template
{# Resources/views/widget/heatmap.html.twig #}
{% block widget_content %}
<div class="heatmap-widget" id="heatmap-container"
data-url="{{ path('heatmap_data') }}"
data-mode="hours"
data-range="365">
<div class="heatmap-controls">
<select id="heatmap-mode">
<option value="hours">Hours per day</option>
<option value="count">Entry count</option>
</select>
<select id="heatmap-project">
<option value="">All projects</option>
</select>
</div>
<div id="heatmap-chart"></div>
</div>
{% endblock %}
{% block widget_javascript %}
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>
{% endblock %}
d3 Module Design
assets/
src/
heatmap.ts # Entry point, initializes widget
calendar-heatmap.ts # d3 calendar heatmap rendering
data-fetcher.ts # Fetch API wrapper for controller endpoint
color-scale.ts # CSS variable-aware color scaling
types.ts # TypeScript interfaces
package.json # d3 + build deps
tsconfig.json
rollup.config.js # Bundle to single file for Resources/public/
The JS gets bundled (via rollup or esbuild) into a single heatmap.js that ships in Resources/public/. d3 is bundled in (tree-shaken to only calendar/scale modules).
CSS Variable Integration
/* Use Kimai's theme CSS custom properties */
.heatmap-cell {
fill: var(--chart-color-empty, #ebedf0);
}
.heatmap-cell[data-level="1"] { fill: var(--chart-color-low, #9be9a8); }
.heatmap-cell[data-level="2"] { fill: var(--chart-color-medium, #40c463); }
.heatmap-cell[data-level="3"] { fill: var(--chart-color-high, #30a14e); }
.heatmap-cell[data-level="4"] { fill: var(--chart-color-max, #216e39); }
Note: Kimai uses AdminLTE / Tabler themes. The actual CSS variable names need to be verified against the running Kimai instance. Fallback values ensure the heatmap works even if variables are missing.
Data Flow
1. User loads Kimai dashboard
2. Symfony renders dashboard, includes HeatmapWidget
3. HeatmapWidget renders Twig template (empty chart container + controls)
4. heatmap.js initializes on DOMContentLoaded
5. JS reads data-url attribute, fetches /api/plugins/heatmap/data?range=365
6. HeatmapController receives request, delegates to HeatmapService
7. HeatmapService queries TimesheetRepository with DQL/QueryBuilder
- GROUP BY DATE(begin), SUM(duration), COUNT(*)
- Filtered by user, date range, optional project
8. Controller returns JSON response
9. d3.js receives JSON, builds calendar heatmap SVG
10. User interactions (mode toggle, project filter, day click) handled client-side
- Mode toggle: re-renders with different data key (hours vs count)
- Project filter: new fetch with ?project=ID parameter
- Day click: window.location to /en/timesheet/?daterange=YYYY-MM-DD
Patterns to Follow
Pattern 1: Widget Data via Dedicated API
What: Widget template is a thin shell; data fetching happens via XHR to a dedicated controller endpoint. Why: Separates rendering from data. Widget loads fast (empty shell), data arrives async. Enables filtering/re-fetching without page reload. When: Always for data-heavy widgets.
Pattern 2: Permission-Gated Everything
What: Both the widget visibility and the API endpoint check view_own_timesheet permission.
Why: Kimai has a role-based permission system. Widget should only appear for users with timesheet access, and the API must independently verify permissions (defense in depth).
Pattern 3: User-Scoped Queries
What: Always filter by $this->getUser() in queries.
Why: Personal time tracking -- users should only see their own data. Even if Kimai handles this at the repository level, be explicit.
Anti-Patterns to Avoid
Anti-Pattern 1: Embedding Data in Twig
What: Passing all heatmap data through getData() into Twig as PHP arrays.
Why bad: Large datasets (365 days of data) bloat the HTML. Cannot update without page reload. Makes filtering require full page requests.
Instead: Thin Twig template + XHR data fetching.
Anti-Pattern 2: Hooking into Kimai's Webpack Build
What: Requiring the plugin user to rebuild Kimai's assets.
Why bad: Kimai's internal asset pipeline is not a public API. Updates can break your build. Extra install step for users.
Instead: Ship prebuilt JS/CSS in Resources/public/.
Anti-Pattern 3: Raw SQL Queries
What: Writing raw SQL instead of using Doctrine QueryBuilder or DQL. Why bad: Breaks database portability (Kimai supports MySQL and SQLite). Harder to test. Instead: Use Doctrine QueryBuilder with Kimai's existing repository patterns.
Suggested Build Order
The components have clear dependencies that dictate implementation order:
Phase 1: Plugin Scaffold
- Bundle class, DI extension, services.yaml, routes.yaml
- Empty widget registered on dashboard (renders "Hello Heatmap")
- Nix devshell with local Kimai instance
Validates: Plugin loads, widget appears, dev env works
Phase 2: Data Layer
- HeatmapService with aggregation queries
- HeatmapController returning JSON
- PHPUnit tests for service + controller
Validates: API returns correct aggregated data
Depends on: Phase 1 (routing, DI)
Phase 3: Heatmap Visualization
- d3.js calendar heatmap module (TypeScript)
- Build pipeline (rollup/esbuild -> single bundle)
- Twig template integration
- JS tests for rendering logic
Validates: Heatmap renders from API data
Depends on: Phase 2 (API endpoint exists)
Phase 4: Interactivity
- Mode toggle (hours vs count)
- Project/activity filter dropdowns
- Day-click navigation to timesheet
- Kimai theme CSS variable integration
Validates: Full feature set works
Depends on: Phase 3 (heatmap renders)
Scalability Considerations
| Concern | Personal use (1 user) | Team (10 users) |
|---|---|---|
| Query performance | Fine, single GROUP BY | Add DB index on timesheet.begin if slow |
| Data payload size | ~365 rows, trivial | Same per user (user-scoped) |
| Widget rendering | Instant | Instant (client-side d3) |
| Caching | Not needed | Consider HTTP cache headers on API |
For personal use (the stated scope), performance is a non-concern. The timesheet table query with a GROUP BY date, filtered to one user and one year, will return at most 365 rows.
Sources
- Kimai plugin development documentation (kimai.org/documentation/plugin-development.html) -- referenced from training data, MEDIUM confidence
- Symfony Bundle system documentation (symfony.com/doc/current/bundles.html) -- HIGH confidence, stable API
- Kimai source code patterns (github.com/kimai/kimai) -- referenced from training data, MEDIUM confidence
- d3.js calendar heatmap patterns -- HIGH confidence, well-established pattern
Confidence Notes
| Area | Confidence | Notes |
|---|---|---|
| Bundle structure | MEDIUM | Based on Kimai 2.x patterns from training data. Namespace KimaiPlugin\ and PluginInterface need verification against current Kimai release. |
| Widget registration | MEDIUM | kimai.widget DI tag and WidgetInterface / AbstractWidgetType hierarchy from training data. Widget interface methods may differ in current release. |
| API controller pattern | HIGH | Standard Symfony controller patterns. Route prefix and permission attribute names should be verified. |
| d3.js integration | HIGH | Self-contained JS bundle in Resources/public/ is the standard Kimai plugin asset approach. |
| Data flow | HIGH | Symfony controller -> Doctrine query -> JSON -> client JS is textbook. |
| CSS theme variables | LOW | Actual Kimai CSS variable names need verification against running instance. Fallback values mitigate risk. |