467 lines
17 KiB
Markdown
467 lines
17 KiB
Markdown
# 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
|
|
|
|
```php
|
|
// 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
|
|
|
|
```php
|
|
// 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
|
|
|
|
```php
|
|
// 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)
|
|
|
|
```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
|
|
|
|
```php
|
|
// 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)
|
|
|
|
```yaml
|
|
heatmap_api:
|
|
resource: '../../Controller/'
|
|
type: annotation # or attribute for PHP 8+
|
|
prefix: /api/plugins/heatmap
|
|
```
|
|
|
|
### JSON Response Shape
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```twig
|
|
{# 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
|
|
|
|
```css
|
|
/* 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. |
|