docs: complete v1.1 project research
This commit is contained in:
parent
a2bc9a31ad
commit
473c19acad
5 changed files with 762 additions and 926 deletions
|
|
@ -1,467 +1,376 @@
|
|||
# Architecture Patterns
|
||||
# Architecture: v1.1 Integration
|
||||
|
||||
**Domain:** Kimai dashboard widget plugin (Symfony bundle + d3.js)
|
||||
**Domain:** Kimai heatmap plugin -- visualization modes, TomSelect pickers, display toggle
|
||||
**Researched:** 2026-04-08
|
||||
**Confidence:** MEDIUM (based on training knowledge of Kimai 2.x plugin system; no live doc verification possible)
|
||||
**Confidence:** HIGH (based on reading existing codebase + Kimai source in dev/)
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
### High-Level Overview
|
||||
## Current Architecture Summary
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| 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 |
|
||||
+------------------------------------------+
|
||||
Twig template (heatmap.html.twig)
|
||||
-- data attributes: data-url, data-projects, data-timesheet-url, data-week-start
|
||||
-- loads heatmap.js (IIFE, KimaiHeatmap global)
|
||||
-- calls KimaiHeatmap.init(container)
|
||||
|
||||
TypeScript (heatmap.ts)
|
||||
init() -- reads data attrs, builds DOM, creates plain <select>, fetches data
|
||||
renderHeatmap() -- d3 year-view calendar grid (single mode)
|
||||
renderStats() -- streak/total/avg/busiest stats row
|
||||
calculateStreak(), calculateStats() -- pure functions
|
||||
|
||||
PHP
|
||||
HeatmapController -- GET /heatmap/data?project=N -> JSON {days, range}
|
||||
HeatmapService -- getDailyAggregation(user, begin, end, projectId) GROUP BY DATE
|
||||
HeatmapWidget -- getData() returns {projects, weekStart}
|
||||
DashboardSubscriber -- registers widget on dashboard
|
||||
```
|
||||
|
||||
## What Needs to Change
|
||||
|
||||
### New Features Mapped to Components
|
||||
|
||||
| Feature | Backend Changes | Frontend Changes | New Components |
|
||||
|---------|----------------|-----------------|----------------|
|
||||
| Visualization modes (year/week/day/combined) | New aggregation queries in HeatmapService | Mode switcher UI, new render functions per mode | `renderers/*.ts` modules |
|
||||
| TomSelect entity pickers | New API endpoints or reuse Kimai's existing `/api/customers`, `/api/projects`, `/api/activities` | Replace plain `<select>` with TomSelect-enhanced selects, cascading logic | Filter bar component in TS |
|
||||
| Activity filtering | Add `activityId` param to controller + service query | Activity picker in cascade after project | None (extends existing) |
|
||||
| Hours/count toggle | None (data already includes both `hours` and `count`) | Toggle button, pass display mode to renderer | None (extends existing) |
|
||||
|
||||
## Integration Architecture
|
||||
|
||||
### 1. Visualization Mode System
|
||||
|
||||
**Current state:** `renderHeatmap()` is a monolithic function that only renders the year-view calendar grid. It handles cells, tooltips, month labels, day labels, and click handlers in one function.
|
||||
|
||||
**Target state:** A mode dispatcher that delegates to mode-specific renderers sharing common infrastructure (tooltip, color scale, click handler).
|
||||
|
||||
```
|
||||
heatmap.ts
|
||||
init()
|
||||
|
|
||||
v
|
||||
+------------------------------------------+
|
||||
| Kimai Database (timesheet table) |
|
||||
| - begin, end, duration, project_id, |
|
||||
| activity_id, user_id |
|
||||
+------------------------------------------+
|
||||
ModeController (new)
|
||||
- activeMode: 'year' | 'week' | 'day' | 'combined'
|
||||
- switchMode(mode) -> fetches appropriate data, calls renderer
|
||||
|
|
||||
+-- renderers/year.ts (refactored from current renderHeatmap)
|
||||
+-- renderers/week.ts (new: 7-column day-of-week aggregation)
|
||||
+-- renderers/day.ts (new: 24-row hour-of-day heatmap)
|
||||
+-- renderers/combined.ts (new: 7x24 matrix, day-of-week x hour)
|
||||
|
|
||||
+-- shared/
|
||||
+-- tooltip.ts (extracted from renderHeatmap)
|
||||
+-- colorScale.ts (extracted from renderHeatmap)
|
||||
+-- types.ts (extended with new data shapes)
|
||||
```
|
||||
|
||||
### Component Boundaries
|
||||
**Renderer interface:**
|
||||
|
||||
| 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);
|
||||
}
|
||||
```typescript
|
||||
interface ModeRenderer {
|
||||
render(
|
||||
container: HTMLElement,
|
||||
data: ModeData,
|
||||
config: HeatmapConfig,
|
||||
onCellClick?: (context: CellClickContext) => void,
|
||||
weekStart?: string,
|
||||
): void;
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
Each renderer is a pure function (no shared mutable state). The mode controller manages which one is active and handles data fetching.
|
||||
|
||||
### DependencyInjection Extension
|
||||
**Backend data requirements per mode:**
|
||||
|
||||
```php
|
||||
// DependencyInjection/KimaiHeatmapExtension.php
|
||||
namespace KimaiPlugin\KimaiHeatmapBundle\DependencyInjection;
|
||||
| Mode | Aggregation | Existing? | API Change |
|
||||
|------|------------|-----------|------------|
|
||||
| Year | GROUP BY DATE | Yes (`getDailyAggregation`) | None |
|
||||
| Week | GROUP BY DAYOFWEEK | No | New method + new `mode=week` param |
|
||||
| Day | GROUP BY HOUR | No | New method + new `mode=day` param |
|
||||
| Combined | GROUP BY DAYOFWEEK, HOUR | No | New method + new `mode=combined` param |
|
||||
|
||||
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;
|
||||
**API endpoint evolution:**
|
||||
|
||||
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');
|
||||
}
|
||||
```
|
||||
Current: GET /heatmap/data?project=N
|
||||
-> {days: [{date, hours, count}], range: {begin, end}}
|
||||
|
||||
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',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
Proposed: GET /heatmap/data?project=N&activity=M&mode=year
|
||||
-> {days: [{date, hours, count}], range: {begin, end}}
|
||||
GET /heatmap/data?project=N&activity=M&mode=week
|
||||
-> {buckets: [{dayOfWeek: 0-6, hours, count}]}
|
||||
GET /heatmap/data?project=N&activity=M&mode=day
|
||||
-> {buckets: [{hour: 0-23, hours, count}]}
|
||||
GET /heatmap/data?project=N&activity=M&mode=combined
|
||||
-> {buckets: [{dayOfWeek: 0-6, hour: 0-23, hours, count}]}
|
||||
```
|
||||
|
||||
The `mode` parameter defaults to `year` for backward compatibility. Different modes return different response shapes via a discriminated union keyed on `mode`.
|
||||
|
||||
### 2. TomSelect Integration
|
||||
|
||||
**The key decision: Don't use Kimai's KimaiFormSelect.js directly.**
|
||||
|
||||
Kimai's `KimaiFormSelect` is deeply coupled to Kimai's plugin container system (`this.getContainer().getPlugin('api')`) and Symfony form rendering. It expects:
|
||||
- A `KimaiContainer` instance providing API, date utils, and translation plugins
|
||||
- Form elements rendered by Symfony with specific `data-*` attributes
|
||||
- Global `document` change event delegation for cascading
|
||||
|
||||
Our widget builds its DOM in JS (not Symfony forms), runs outside Kimai's form lifecycle, and ships as an IIFE bundle. Trying to hook into `KimaiFormSelect` would require either:
|
||||
1. Importing TomSelect + reimplementing cascade logic (the sensible path)
|
||||
2. Somehow accessing Kimai's JS plugin container from our IIFE scope (fragile, undocumented)
|
||||
|
||||
**Recommendation: Import tom-select directly and implement cascade logic ourselves.**
|
||||
|
||||
The cascade pattern from `KimaiFormSelect._activateApiSelects()` is straightforward: on parent change, fetch child options from API, update child select. We can replicate the essential behavior in ~50 lines without the form framework coupling.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// filters/EntityPicker.ts
|
||||
import TomSelect from 'tom-select';
|
||||
|
||||
interface PickerConfig {
|
||||
element: HTMLSelectElement;
|
||||
apiUrl: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string | null) => void;
|
||||
dependsOn?: EntityPicker; // cascade parent
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboard Widget System
|
||||
**Cascade flow:**
|
||||
```
|
||||
Customer picker (optional, loads from /api/customers)
|
||||
|-- on change -> reload Project picker from /api/projects?customer=X
|
||||
|-- on change -> reload Activity picker from /api/activities?project=Y
|
||||
|-- on change -> refetch heatmap data with all filters
|
||||
```
|
||||
|
||||
Kimai's dashboard renders widgets that implement `App\Widget\WidgetInterface`. Widgets are registered via Symfony DI tags.
|
||||
**API endpoints to use:** Kimai's existing REST API routes (`get_customers`, `get_projects`, `get_activities`) already exist and return data in the format TomSelect expects. No new backend endpoints needed for the pickers themselves.
|
||||
|
||||
### Widget Interface
|
||||
**Bundle size concern:** tom-select is ~30KB minified+gzipped. Since Kimai already loads it globally (it's in Kimai's own webpack bundle), we should NOT bundle it into our IIFE. Instead, reference the global `TomSelect` that Kimai already provides.
|
||||
|
||||
```php
|
||||
// Widget/HeatmapWidget.php
|
||||
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
|
||||
**Verification needed:** Check whether `window.TomSelect` or `TomSelect` is available globally in the Kimai dashboard page. If Kimai's webpack build exposes it, we use it. If not, we bundle our own copy.
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
use App\Widget\Type\AbstractWidgetType;
|
||||
use App\Repository\TimesheetRepository;
|
||||
### 3. Display Toggle (Hours vs Count)
|
||||
|
||||
class HeatmapWidget extends AbstractWidgetType
|
||||
{
|
||||
public function __construct(
|
||||
private TimesheetRepository $timesheetRepository
|
||||
) {}
|
||||
**This is the simplest change.** The data already contains both `hours` and `count` per day entry. The toggle is purely a frontend concern:
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return WidgetInterface::WIDTH_FULL; // Full-width dashboard widget
|
||||
}
|
||||
- Add a segmented control or toggle button to the UI
|
||||
- Pass `displayMode: 'hours' | 'count'` to the renderer
|
||||
- Renderer uses `entry.hours` or `entry.count` for the color scale domain
|
||||
- Tooltip text adjusts accordingly
|
||||
|
||||
public function getHeight(): int
|
||||
{
|
||||
return WidgetInterface::HEIGHT_LARGE;
|
||||
}
|
||||
**No backend changes needed.** The year-mode API already returns both fields. For new modes (week/day/combined), the aggregation queries should also return both.
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Activity Heatmap';
|
||||
}
|
||||
### 4. Filter Bar Layout
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return '@KimaiHeatmap/widget/heatmap.html.twig';
|
||||
}
|
||||
**Current layout:** Heatmap SVG area + project dropdown in a flex row, stats below.
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'HeatmapWidget';
|
||||
}
|
||||
**New layout:**
|
||||
|
||||
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 [];
|
||||
}
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| Filter bar: [Customer v] [Project v] [Activity v] [Year|Week|Day|Combined] [Hours|Count] |
|
||||
+------------------------------------------------------------------+
|
||||
| Heatmap SVG area (mode-dependent) |
|
||||
+------------------------------------------------------------------+
|
||||
| Stats row: streak | total | avg | busiest |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return ['view_own_timesheet'];
|
||||
}
|
||||
The filter bar replaces the current `heatmap-filter` div. It should use Tabler's form layout classes (`row`, `col-auto`) for alignment with the rest of the Kimai dashboard.
|
||||
|
||||
## Component Inventory
|
||||
|
||||
### New TypeScript Files
|
||||
|
||||
| File | Purpose | Depends On |
|
||||
|------|---------|-----------|
|
||||
| `renderers/year.ts` | Refactored year-view (extracted from renderHeatmap) | shared/tooltip, shared/colorScale |
|
||||
| `renderers/week.ts` | Day-of-week aggregation heatmap | shared/tooltip, shared/colorScale |
|
||||
| `renderers/day.ts` | Hour-of-day heatmap | shared/tooltip, shared/colorScale |
|
||||
| `renderers/combined.ts` | 7x24 day/hour matrix | shared/tooltip, shared/colorScale |
|
||||
| `shared/tooltip.ts` | Tooltip creation and positioning (extracted) | None |
|
||||
| `shared/colorScale.ts` | Color scale factory (extracted) | None |
|
||||
| `filters.ts` | TomSelect entity pickers with cascade | tom-select (global or bundled) |
|
||||
| `modeController.ts` | Mode switching, data fetching orchestration | renderers/*, filters |
|
||||
|
||||
### Modified TypeScript Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `heatmap.ts` | `init()` refactored to use ModeController and Filters instead of inline DOM building |
|
||||
| `types.ts` | New interfaces: `WeekBucket`, `HourBucket`, `CombinedBucket`, `ModeData` union type, `FilterState` |
|
||||
|
||||
### New PHP Files
|
||||
|
||||
None needed -- extend existing files.
|
||||
|
||||
### Modified PHP Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `HeatmapService.php` | Add `getWeekdayAggregation()`, `getHourlyAggregation()`, `getCombinedAggregation()`, add `activityId` param to all methods |
|
||||
| `HeatmapController.php` | Add `mode` and `activity` query params, dispatch to appropriate service method |
|
||||
| `HeatmapWidget.php` | No changes (pickers load via API, not server-side data) |
|
||||
| `heatmap.html.twig` | Add data attributes for API URLs (`data-customers-url`, `data-projects-url`, `data-activities-url`), remove `data-projects` (no longer server-rendered) |
|
||||
|
||||
### New CSS
|
||||
|
||||
| Addition | Purpose |
|
||||
|---------|---------|
|
||||
| `.heatmap-toolbar` | Filter bar layout (flex, gap, wrapping) |
|
||||
| `.heatmap-mode-switcher` | Segmented control for mode tabs |
|
||||
| `.heatmap-display-toggle` | Hours/count toggle button |
|
||||
| TomSelect overrides | Match Kimai's existing TomSelect styling (should inherit, may need minor tweaks) |
|
||||
|
||||
## Data Flow Changes
|
||||
|
||||
### Current
|
||||
|
||||
```
|
||||
init() -> fetch(/heatmap/data) -> renderHeatmap(yearData) -> renderStats()
|
||||
filter change -> fetch(/heatmap/data?project=N) -> renderHeatmap(yearData)
|
||||
```
|
||||
|
||||
### New
|
||||
|
||||
```
|
||||
init()
|
||||
-> build filter bar (TomSelect pickers, mode tabs, display toggle)
|
||||
-> fetch(/heatmap/data?mode=year) -> yearRenderer.render()
|
||||
-> renderStats()
|
||||
|
||||
customer change -> cascade: reload projects -> reload activities -> refetch
|
||||
project change -> cascade: reload activities -> refetch
|
||||
activity change -> refetch
|
||||
mode change -> refetch with new mode param -> modeRenderer.render()
|
||||
display toggle -> re-render with same data, different value accessor (no fetch)
|
||||
```
|
||||
|
||||
**Key insight:** The display toggle (hours/count) does NOT require a new fetch. It re-renders the current data with a different accessor. All other changes (filter, mode) trigger a new fetch.
|
||||
|
||||
### State Management
|
||||
|
||||
Current state is implicit (closure variables in `init()`). With more state dimensions, formalize it:
|
||||
|
||||
```typescript
|
||||
interface HeatmapState {
|
||||
mode: 'year' | 'week' | 'day' | 'combined';
|
||||
display: 'hours' | 'count';
|
||||
filters: {
|
||||
customerId: number | null;
|
||||
projectId: number | null;
|
||||
activityId: number | null;
|
||||
};
|
||||
data: ModeData | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
Keep it simple -- a plain object in `init()` closure, not a state management library. When any filter/mode changes, build the fetch URL from state and re-render. When display changes, re-render from cached `state.data`.
|
||||
|
||||
## Suggested Build Order
|
||||
|
||||
The components have clear dependencies that dictate implementation order:
|
||||
Dependencies dictate this sequence:
|
||||
|
||||
```
|
||||
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 1: Refactor Existing Code (Foundation)
|
||||
|
||||
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)
|
||||
Extract shared utilities from `renderHeatmap()` -- tooltip, color scale, cell click handler. Create the renderer interface. Refactor year-view into `renderers/year.ts` that implements it. Create `types.ts` extensions.
|
||||
|
||||
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)
|
||||
**Why first:** Everything else depends on the renderer abstraction. This phase changes no behavior -- existing tests should still pass (or need minor import path updates).
|
||||
|
||||
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)
|
||||
**Validates:** Year-view still works identically after refactor.
|
||||
|
||||
### Phase 2: Display Toggle + Mode Switcher UI
|
||||
|
||||
Add the hours/count toggle (purely frontend, uses existing data). Add the mode switcher UI (tabs/segmented control) but only year mode works initially.
|
||||
|
||||
**Why second:** Low risk, immediate visible result, validates the UI shell before adding complex renderers.
|
||||
|
||||
**Validates:** Toggle switches between hours and count display. Mode tabs render but only year works.
|
||||
|
||||
### Phase 3: Backend Aggregation Modes + Activity Filtering
|
||||
|
||||
Add `getWeekdayAggregation()`, `getHourlyAggregation()`, `getCombinedAggregation()` to HeatmapService. Add `mode` and `activity` params to controller. PHPUnit tests for all new queries.
|
||||
|
||||
**Why third:** Renderers need data to render. Get the API right before building the visualizations.
|
||||
|
||||
**Validates:** API returns correct data for all modes. Activity filter works.
|
||||
|
||||
### Phase 4: New Visualization Renderers
|
||||
|
||||
Implement `renderers/week.ts`, `renderers/day.ts`, `renderers/combined.ts`. Wire mode switcher to actually switch renderers.
|
||||
|
||||
**Why fourth:** Backend data is ready, UI shell exists, just fill in the renderers.
|
||||
|
||||
**Validates:** All four modes render correctly with real data.
|
||||
|
||||
### Phase 5: TomSelect Entity Pickers
|
||||
|
||||
Replace plain `<select>` with TomSelect-enhanced pickers. Implement customer -> project -> activity cascade. Update Twig template with API URL data attributes.
|
||||
|
||||
**Why last:** Most complex integration point (TomSelect availability, cascade logic, API compatibility). Everything else works with the existing project dropdown. This phase upgrades the filtering UX without blocking other features.
|
||||
|
||||
**Validates:** Cascade works, filtering triggers data refetch, TomSelect styling matches Kimai.
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern: Giant Switch Statement in renderHeatmap()
|
||||
|
||||
**What:** Adding `if (mode === 'week') { ... } else if (mode === 'day') { ... }` branches to the existing function.
|
||||
**Why bad:** The function is already 120+ lines. Adding 3 more modes would make it unmaintainable.
|
||||
**Instead:** Separate renderer modules with a shared interface.
|
||||
|
||||
### Anti-Pattern: Importing Kimai's KimaiFormSelect.js
|
||||
|
||||
**What:** Trying to use Kimai's internal form select system for TomSelect.
|
||||
**Why bad:** Coupled to Kimai's JS plugin container, Symfony form lifecycle, and internal APIs. Would break on Kimai updates.
|
||||
**Instead:** Import tom-select directly (or use Kimai's global). Implement cascade in ~50 lines.
|
||||
|
||||
### Anti-Pattern: Separate API Endpoints per Mode
|
||||
|
||||
**What:** Creating `/heatmap/data/week`, `/heatmap/data/day`, etc.
|
||||
**Why bad:** Multiplies routes and controllers unnecessarily.
|
||||
**Instead:** Single endpoint with `mode` query parameter. One controller method dispatches to the right service method.
|
||||
|
||||
### Anti-Pattern: Bundling TomSelect into IIFE
|
||||
|
||||
**What:** Including tom-select in the esbuild bundle.
|
||||
**Why bad:** Kimai already loads tom-select globally. Bundling a duplicate wastes ~30KB and may cause version conflicts.
|
||||
**Instead:** Declare tom-select as external in esbuild config, reference the global. Fall back to bundled copy only if global isn't available.
|
||||
|
||||
## TomSelect Global Availability -- Verification Plan
|
||||
|
||||
Before implementing Phase 5, verify in the dev environment:
|
||||
|
||||
```javascript
|
||||
// In browser console on Kimai dashboard:
|
||||
typeof TomSelect !== 'undefined' // Check global
|
||||
document.querySelector('[data-renderer]') // Check if Kimai's selects use TomSelect
|
||||
```
|
||||
|
||||
## Scalability Considerations
|
||||
If TomSelect is NOT globally available (Kimai bundles it but doesn't expose it), options are:
|
||||
1. Add `tom-select` to our npm dependencies and bundle it (adds ~30KB)
|
||||
2. Use `esbuild --external:tom-select` and add a shim that extracts it from Kimai's bundle
|
||||
|
||||
| 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.
|
||||
Option 1 is the safe default. Option 2 is fragile.
|
||||
|
||||
## 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
|
||||
- Kimai source: `dev/kimai/assets/js/forms/KimaiFormSelect.js` (cascade logic, lines 386-447)
|
||||
- Kimai source: `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php` (API data attributes)
|
||||
- Kimai API routes: `get_customers`, `get_projects`, `get_activities` (existing REST endpoints)
|
||||
- Existing codebase: all files read from `src/`, `assets/src/`, `Resources/`
|
||||
|
||||
## Confidence Notes
|
||||
## Confidence Assessment
|
||||
|
||||
| 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. |
|
||||
| Renderer refactor pattern | HIGH | Standard strategy pattern, well-understood |
|
||||
| Backend aggregation queries | HIGH | Simple SQL GROUP BY variations on existing working query |
|
||||
| Display toggle | HIGH | Pure frontend, data already present |
|
||||
| TomSelect integration approach | MEDIUM | Need to verify global availability; cascade logic is clear from reading KimaiFormSelect source |
|
||||
| Mode switcher UI | HIGH | Tabler provides segmented controls out of the box |
|
||||
| API backward compatibility | HIGH | Adding optional `mode` param with `year` default preserves existing behavior |
|
||||
|
|
|
|||
|
|
@ -1,130 +1,138 @@
|
|||
# Feature Landscape
|
||||
|
||||
**Domain:** Time-tracking activity heatmap dashboard widget (Kimai plugin)
|
||||
**Domain:** Time-tracking heatmap dashboard widget (v1.1 milestone -- modes, filtering, toggles)
|
||||
**Researched:** 2026-04-08
|
||||
**Overall confidence:** MEDIUM (based on training data knowledge of GitHub, GitLab, WakaTime, RescueTime, Toggl, and open-source heatmap libraries; no live verification available)
|
||||
|
||||
## Reference Products Analyzed
|
||||
|
||||
| Product | Heatmap Style | Key Insight |
|
||||
|---------|--------------|-------------|
|
||||
| GitHub contribution graph | Year-long calendar grid, 5 color levels, tooltips | The gold standard UX -- simple, instantly understood, no configuration needed |
|
||||
| GitLab activity calendar | Same layout as GitHub, slightly different color scheme | Proves the pattern is universal for developer audiences |
|
||||
| WakaTime | Calendar heatmap + line charts + breakdowns by language/project | Heavier analytics layer on top of the basic heatmap |
|
||||
| RescueTime | Daily productivity scores, category breakdowns, hour-of-day patterns | Focus on productivity categorization, not raw activity |
|
||||
| Toggl Track insights | Weekly/monthly bar charts, project pie charts, calendar view | More report-centric than heatmap-centric |
|
||||
| cal-heatmap (JS library) | Configurable calendar heatmap, multiple layouts | Popular open-source implementation, good API reference |
|
||||
|
||||
## Table Stakes
|
||||
|
||||
Features users expect from any calendar heatmap. Missing any of these makes the widget feel broken or incomplete.
|
||||
Features that users of a multi-mode heatmap widget would expect once modes are advertised. Missing any of these makes the feature set feel unfinished.
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Calendar grid layout (weeks x days) | This IS the product -- the GitHub-style grid is the universally recognized pattern | Medium | d3.js renders SVG grid; each cell = one day |
|
||||
| Color intensity mapping | Users expect darker = more activity, lighter = less; this is how every heatmap works | Low | 4-5 discrete color levels (empty, low, medium, high, very high) |
|
||||
| Tooltip on hover | Every reference product shows exact values on hover; without it users stare at colors guessing | Low | Show date, hours tracked, entry count |
|
||||
| Sensible default time range | GitHub shows 1 year; users expect to see something useful without configuration | Low | Default to trailing 12 months |
|
||||
| Day-of-week labels | Mon/Wed/Fri labels on the Y-axis for orientation | Low | Standard in GitHub/GitLab graphs |
|
||||
| Month labels | Column headers showing month boundaries | Low | Standard in all calendar heatmaps |
|
||||
| Empty state | Days with no entries should render as a distinct "no data" color, not be invisible | Low | Light gray or the theme's muted background color |
|
||||
| Click-through to detail | Clicking a day should take you somewhere useful | Low | Navigate to Kimai timesheet view filtered to that date (already in PROJECT.md requirements) |
|
||||
| Theme integration | Must not look like a foreign element glued onto the dashboard | Low | Use Kimai CSS variables for colors |
|
||||
| Mode switcher UI | Users need a way to switch views. Without it, modes are invisible. | Low | Use Tabler's `nav-segmented` component -- it exists specifically for toggling views within the same context. Maps to `data-bs-toggle="tab"`. Max 4-5 segments. |
|
||||
| Year-view heatmap (existing) | Already shipped in v1.0. Must remain default mode. | Done | Existing `renderHeatmap()` in `heatmap.ts` |
|
||||
| Day-of-week aggregation (week mode) | Standard "punchcard" analysis -- which weekdays are busiest. Every time-tracking analytics tool offers this. | Medium | Aggregate existing `DayEntry[]` client-side by `Date.getDay()`. No new backend query needed -- sum/average existing daily data by weekday. 7-row or 7-column chart with color intensity. |
|
||||
| Hours vs entry-count toggle | The data already has both `hours` and `count` fields. Users expect to choose which metric colors the heatmap. | Low | Toggle the `colorScale` domain between `hours` and `count`. A small segmented control or pair of radio buttons. Applies to all modes. |
|
||||
| Cascading entity pickers (customer -> project -> activity) | Kimai users see TomSelect pickers everywhere else in the app. A plain `<select>` feels foreign. Once activity filtering is promised, the cascade is expected. | Medium-High | See Complexity Notes below for full analysis of integration approach. |
|
||||
| Activity filtering | Descoped from v1.0. Now the primary reason for the entity picker upgrade. Users who filter by project will expect activity filtering too. | Medium | Backend: add `?activity=N` param to `HeatmapController::data()`, add `AND t.activity = :activity` clause to `HeatmapService::getDailyAggregation()`. Frontend: wire activity picker into fetch URL. Kimai API route `get_activities` already accepts `?project=N` for cascading. |
|
||||
| Persistent filter/mode state | Switching modes or filters then refreshing should not reset everything. | Low | Use URL query params (shareable, bookmarkable) or `localStorage`. URL params preferred. |
|
||||
|
||||
## Differentiators
|
||||
|
||||
Features that elevate the widget beyond a static pretty picture. Not expected by default, but each adds meaningful value for a personal time-tracking use case.
|
||||
Features that elevate this from "yet another heatmap" to a genuinely useful time-analysis tool. Not expected, but valued.
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| Toggle between hours and entry count | Different questions: "how much did I work?" vs "how consistently did I track?" -- GitHub only shows one metric | Low | Button or toggle in widget header; already in PROJECT.md requirements |
|
||||
| Project/activity filter | Focus on one project at a time to see its cadence | Medium | Dropdown populated from Kimai data; re-renders heatmap on change |
|
||||
| Configurable time range | See last 3 months, 6 months, full year, or custom range | Medium | Range selector in widget header; affects data query and grid size |
|
||||
| Streak indicator | "Current streak: 12 days" -- gamification that motivates consistency | Low | Simple calculation: count consecutive non-zero days ending at today |
|
||||
| Summary stats row | Total hours, average hours/day, busiest day -- quick numbers alongside the visual | Low | Small text row below or above the heatmap |
|
||||
| Weekend vs weekday distinction | Subtle visual indicator (border, opacity) for weekends | Low | Helps distinguish work patterns from weekend work |
|
||||
| Responsive widget sizing | Widget that adapts if Kimai dashboard column width changes | Medium | SVG viewBox scaling; cell size calculation based on container width |
|
||||
| Time-of-day heatmap (day mode) | Shows *when during the day* work happens. Reveals morning-person vs night-owl patterns. Uncommon in time-tracking tools. | High | Requires new backend query: `GROUP BY HOUR(t.begin)` for hour-level aggregation. New data shape: `HourEntry { hour: number, hours: number, count: number }`. New d3 layout: 24-row/column grid. Needs new API response format or a `mode` query param. |
|
||||
| Combined day/hour matrix | Full punchcard: 7 rows (days) x 24 cols (hours). The GitHub punchcard view. Shows exactly when work happens across the week. | High | Backend: `GROUP BY DAYOFWEEK(t.begin), HOUR(t.begin)`. New data shape: `PunchcardEntry { day: number, hour: number, hours: number, count: number }`. d3 layout: 7x24 grid with circle sizes or color intensity. Most complex visualization but highest insight value. |
|
||||
| Customer-level filtering | Filter heatmap by customer (one level above project). Useful for seeing client-specific patterns. | Low | The cascade handles this naturally: customer picker drives project picker via `get_projects?customer=N`. Backend `HeatmapService` needs `?customer=N` param that joins through `t.project.customer`. |
|
||||
| Color scale legend | Small gradient bar showing what colors mean (0h to Xh). | Low | Standard d3 pattern. Horizontal gradient with tick labels. Applies to all modes. |
|
||||
| Animated transitions between modes | Smooth morphing when switching views (cells rearrange, fade, resize). | Medium | d3 `transition()` API. Key: use consistent `key` functions in `.join()` so d3 matches elements across renders. Polish, not critical. |
|
||||
|
||||
## Anti-Features
|
||||
|
||||
Things to deliberately NOT build. Each would add complexity without serving the core "at a glance, see where your time went" purpose.
|
||||
Things to explicitly NOT build in v1.1.
|
||||
|
||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||
|--------------|-----------|-------------------|
|
||||
| Real-time updates / live refresh | Overengineered for a dashboard widget; page reload is fine for daily granularity data | Refresh data on page load only |
|
||||
| Billable vs non-billable split | Out of scope per PROJECT.md; this is personal tracking, not client billing | Single aggregate view |
|
||||
| Export/share heatmap as image | Out of scope per PROJECT.md; no audience to share with for personal use | Users can screenshot if needed |
|
||||
| Hour-of-day heatmap (2D matrix) | Different visualization entirely; scope creep that doubles the frontend work | Stick to the calendar grid; could be a separate widget later |
|
||||
| Drill-down charts within the widget | Clicking a day to show a pie chart of projects within the widget adds major complexity | Click-through to Kimai's existing timesheet/report views instead |
|
||||
| Multi-user comparison | Personal tracking tool, not a team dashboard | Single-user view only |
|
||||
| Goal setting / targets | Adds state management, settings UI, persistence -- a separate feature domain | Streak indicator covers the motivational angle simply |
|
||||
| Animated transitions on data change | Cool but adds JS complexity for near-zero utility on a dashboard widget | Instant re-render on filter change |
|
||||
| Mobile-specific layout | Out of scope per PROJECT.md; Kimai dashboard is a desktop experience | Standard responsive SVG is sufficient |
|
||||
| Custom color theme picker | Theme integration with Kimai variables is sufficient; custom palettes add a settings UI nobody needs | Use Kimai theme variables, pick a single well-tested palette |
|
||||
| Configurable date range selector | Adds significant UI complexity (date pickers, presets). The fixed 1-year window works for personal use. | Defer to v1.2+. If needed later, a simple "6mo / 1yr / 2yr" segmented control is sufficient. |
|
||||
| Multi-user comparison | This is a personal tracking tool, not a team dashboard. | Out of scope per PROJECT.md. |
|
||||
| Export/share heatmap as image | No audience for a personal tool. SVG-to-PNG is fragile. | Out of scope per PROJECT.md. |
|
||||
| Custom color theme picker | Kimai theme integration is sufficient. Adding a color picker adds options without insight. | Keep hardcoded green scale (matches GitHub convention). |
|
||||
| Real-time / auto-refresh | Refresh-on-load is fine. WebSocket or polling adds complexity for no gain. | Out of scope per PROJECT.md. |
|
||||
| Full Kimai form plugin integration | Kimai's `KimaiFormPlugin` lifecycle (`activateForm`/`destroyForm`) is designed for Symfony form pages, not standalone widgets. Coupling to it means depending on Kimai's internal JS architecture. | Use TomSelect directly. Replicate only the cascading pattern. Style with Kimai's existing TomSelect CSS (already loaded globally). See Complexity Notes. |
|
||||
| Drag-to-select date ranges | Complex interaction, ambiguous purpose (filter? zoom? export?). | Click-to-navigate to timesheet (existing) is sufficient. |
|
||||
| Drill-down charts within widget | Clicking a day to show a pie chart of projects adds major complexity. | Click-through to Kimai's existing timesheet/report views instead. |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
Calendar grid layout
|
||||
--> Color intensity mapping (needs the grid to color)
|
||||
--> Tooltip on hover (needs cells to attach to)
|
||||
--> Click-through to detail (needs cells to click)
|
||||
--> Empty state (needs grid cells for empty days)
|
||||
--> Day/Month labels (needs the grid structure)
|
||||
|
||||
Toggle hours/count
|
||||
--> Requires backend to return BOTH hours and count per day
|
||||
--> Re-maps color intensity without re-fetching data
|
||||
|
||||
Project/activity filter
|
||||
--> Requires backend filter parameter in data API
|
||||
--> Dropdown populated via separate Kimai API call (projects list)
|
||||
--> Triggers data re-fetch + full heatmap re-render
|
||||
|
||||
Configurable time range
|
||||
--> Requires backend date range parameter in data API
|
||||
--> Affects grid dimensions (fewer weeks = smaller grid)
|
||||
--> Triggers data re-fetch + full heatmap re-render
|
||||
|
||||
Streak indicator
|
||||
--> Depends on the same daily aggregation data as the heatmap
|
||||
--> No additional backend work if heatmap data is already loaded
|
||||
|
||||
Summary stats
|
||||
--> Depends on the same daily aggregation data as the heatmap
|
||||
--> Pure frontend calculation from existing data
|
||||
Customer picker -> Project picker -> Activity picker (cascade chain)
|
||||
Activity picker -> Activity filter param in API (backend support)
|
||||
Mode switcher UI -> All visualization modes (renders)
|
||||
Hours/count toggle -> Color scale recalculation (applies to all modes)
|
||||
Day mode (time-of-day) -> New backend aggregation query (HOUR grouping)
|
||||
Combined matrix -> New backend aggregation query (DAY + HOUR grouping)
|
||||
Week mode (day-of-week) -> NO new backend query (aggregate DayEntry[] client-side)
|
||||
```
|
||||
|
||||
## MVP Recommendation
|
||||
|
||||
**Phase 1 -- Core heatmap (all table stakes):**
|
||||
1. Calendar grid with color intensity (the product itself)
|
||||
2. Tooltips on hover
|
||||
3. Day/month labels and empty state
|
||||
4. Click-through to Kimai timesheet view
|
||||
5. Kimai theme integration
|
||||
**Phase structure for v1.1:**
|
||||
|
||||
**Phase 2 -- Interactivity (high-value differentiators):**
|
||||
1. Toggle between hours and entry count
|
||||
2. Project/activity filter dropdown
|
||||
3. Configurable time range
|
||||
Phase 1 -- Mode Switcher + Week Mode + Toggle:
|
||||
1. **Mode switcher UI** -- Tabler `nav-segmented`, initially Year (existing) + Week modes
|
||||
2. **Week-mode (day-of-week)** -- Client-side aggregation of existing daily data, no backend changes, proves the multi-mode rendering architecture
|
||||
3. **Hours/count toggle** -- Small segmented control, applies to all modes, low effort
|
||||
4. **Rendering refactor** -- Extract shared concerns (SVG container, color scale, tooltip) into base; modes provide layout strategy
|
||||
|
||||
**Phase 3 -- Polish (low-effort differentiators):**
|
||||
1. Streak indicator
|
||||
2. Summary stats row
|
||||
3. Weekend distinction
|
||||
Phase 2 -- Entity Pickers + Activity Filtering:
|
||||
5. **TomSelect entity pickers** -- Replace plain `<select>` with TomSelect instances, wire cascading via Kimai's API routes (`get_projects?customer=N`, `get_activities?project=N`)
|
||||
6. **Activity filtering** -- Backend `?activity=N` param + frontend wiring
|
||||
7. **Customer filtering** -- Backend `?customer=N` param (join through project.customer)
|
||||
|
||||
**Defer indefinitely:**
|
||||
- Hour-of-day matrix, export, goals, animations, multi-user -- all anti-features for this context.
|
||||
Phase 3 -- Advanced Modes (defer or stretch):
|
||||
8. **Day mode (time-of-day)** -- Needs new backend query + new data shape
|
||||
9. **Combined day/hour matrix** -- Most complex layout, depends on hour-level backend data
|
||||
10. **Color scale legend** -- Nice polish, do alongside advanced modes
|
||||
|
||||
**Rationale:** Phase 1 delivers a complete, useful widget. Phase 2 adds the interactivity that makes it genuinely better than just looking at Kimai's existing reports. Phase 3 is cheap polish that improves daily experience but isn't blocking.
|
||||
**Rationale:** Phase 1 proves the multi-mode rendering architecture with zero backend changes. Phase 2 improves filtering UX with Kimai-native patterns and enables activity filtering. Phase 3 adds the data-intensive visualizations requiring new queries. Each phase delivers user-visible value independently.
|
||||
|
||||
## Complexity Notes
|
||||
|
||||
### TomSelect Integration (the hardest part that looks easy)
|
||||
|
||||
Kimai's TomSelect integration runs through a plugin lifecycle: `KimaiFormPlugin` -> `KimaiFormTomselectPlugin` -> `KimaiFormSelect`. The cascading logic in `_activateApiSelects()` (line 386-447 of `KimaiFormSelect.js`) listens for `change` events on selects with `data-related-select` attributes, fetches from `data-api-url` with form field interpolation (`%fieldname%` patterns), and updates the target select via `_updateOptions()` which dispatches `data-reloaded` events.
|
||||
|
||||
**Why the plugin cannot use Kimai's system directly:**
|
||||
- Requires Kimai's `KimaiContainer` DI (for `getPlugin('api')`)
|
||||
- Requires `KimaiFormPlugin` base class registration
|
||||
- URL interpolation (`%fieldname%`) assumes Symfony form field naming conventions
|
||||
- Lifecycle hooks (`activateForm`/`destroyForm`) expect to be called by Kimai's page init
|
||||
|
||||
**Two integration approaches:**
|
||||
|
||||
**(a) Direct TomSelect instantiation (recommended):**
|
||||
- Import TomSelect standalone (already loaded in Kimai's global JS)
|
||||
- Create `<select>` elements styled with Kimai's TomSelect CSS classes
|
||||
- Implement simplified cascading: on customer change, `fetch('/api/projects?customer=N')`, rebuild project options, trigger activity reload
|
||||
- Use same option grouping pattern (`parentTitle` for optgroups) as Kimai API responses
|
||||
- ~100-150 lines of cascading logic, much simpler than Kimai's generic system
|
||||
|
||||
**(b) Symfony form fields in Twig template:**
|
||||
- Render actual `CustomerType`/`ProjectType`/`ActivityType` form fields in the widget's Twig template
|
||||
- Let Kimai's existing JS auto-enhance them with TomSelect
|
||||
- Cleaner integration but requires investigation: do Kimai's form plugins activate inside widget Twig blocks?
|
||||
- Risk: widget Twig templates may not trigger Kimai's form initialization JS
|
||||
|
||||
### Rendering Architecture Refactor
|
||||
|
||||
Current `renderHeatmap()` is tightly coupled to the year-view grid layout. Adding modes requires a strategy pattern:
|
||||
|
||||
- **Shared infrastructure:** SVG container creation, color scale, tooltip, empty state, resize handler
|
||||
- **Mode-specific:** `generateCells()` / layout function, axis labels, dimensions
|
||||
- Extract into: `createRenderer(mode)` that returns the appropriate layout strategy
|
||||
- Each mode is a self-contained module: `yearMode.ts`, `weekMode.ts`, `dayMode.ts`, `combinedMode.ts`
|
||||
|
||||
### Backend Aggregation Queries
|
||||
|
||||
| Mode | SQL Aggregation | New Backend Code? |
|
||||
|------|-----------------|-------------------|
|
||||
| Year (existing) | `GROUP BY DATE(t.date)` | No |
|
||||
| Week (day-of-week) | Client-side aggregation of daily data | No |
|
||||
| Day (time-of-day) | `GROUP BY HOUR(t.begin)` | Yes -- new method in `HeatmapService` |
|
||||
| Combined | `GROUP BY DAYOFWEEK(t.begin), HOUR(t.begin)` | Yes -- new method in `HeatmapService` |
|
||||
|
||||
Note: day and combined modes need `t.begin` (timesheet start datetime), not `t.date`. Verify `t.begin` is available and contains time component in Kimai's Timesheet entity.
|
||||
|
||||
## Sources
|
||||
|
||||
- GitHub contribution graph: well-known UX pattern (5 color levels, year grid, tooltips, contribution count)
|
||||
- GitLab activity calendar: same pattern, confirms universality
|
||||
- WakaTime dashboard: adds analytics layer (coding time heatmap, project breakdowns, streak tracking)
|
||||
- RescueTime: productivity scoring approach, hour-of-day patterns
|
||||
- Toggl Track insights: report-centric visualizations, project breakdowns
|
||||
- cal-heatmap.com: popular open-source JS calendar heatmap library, API design reference
|
||||
- All sourced from training data (no live verification available) -- MEDIUM confidence
|
||||
- Tabler segmented control: https://docs.tabler.io/ui/components/segmented-control
|
||||
- GitHub PunchCard visualization: https://github.com/vnau/punchcard
|
||||
- Segmented control UX best practices: https://mobbin.com/glossary/segmented-control
|
||||
- Apple HIG segmented controls: https://developer.apple.com/design/human-interface-guidelines/segmented-controls
|
||||
- d3 heatmap patterns: https://d3-graph-gallery.com/heatmap.html
|
||||
- Kimai form select cascading: local `dev/kimai/assets/js/forms/KimaiFormSelect.js` (lines 386-447)
|
||||
- Kimai API routes: `get_projects` at `src/API/ProjectController.php:56`, `get_activities` at `src/API/ActivityController.php:54`
|
||||
- Kimai SelectWithApiDataExtension: local `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php`
|
||||
- TomSelect: https://tom-select.js.org/
|
||||
|
|
|
|||
|
|
@ -1,178 +1,162 @@
|
|||
# Domain Pitfalls
|
||||
|
||||
**Domain:** Kimai dashboard widget plugin (Symfony bundle + d3.js heatmap)
|
||||
**Domain:** Kimai heatmap plugin v1.1 -- visualization modes, TomSelect entity pickers, cascading filters, display toggle
|
||||
**Researched:** 2026-04-08
|
||||
**Note:** Web search/fetch tools were unavailable. Findings are based on training data knowledge of Kimai, Symfony bundles, d3.js, and Nix+PHP environments. All findings marked with confidence levels accordingly.
|
||||
**Confidence:** HIGH (based on direct source analysis of existing plugin code and Kimai's KimaiFormSelect internals)
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
Mistakes that cause rewrites or major issues.
|
||||
|
||||
### Pitfall 1: Kimai Major Version Breaking Changes in Plugin API
|
||||
### Pitfall 1: KimaiFormSelect Depends on Kimai's Plugin Container -- Cannot Use It Standalone
|
||||
|
||||
**What goes wrong:** SEED-001 suggests using Kimai's `KimaiFormSelect.js` with `data-api-url`, `data-related-select`, and `data-empty-url` attributes to get cascading for free. But `KimaiFormSelect._activateApiSelects()` calls `this.getContainer().getPlugin('api')` to make API requests (line 433-435). This requires the select to be inside a form that Kimai's JS plugin system initialized via `activateForm()`. The heatmap widget is a dashboard card, not a Symfony form.
|
||||
|
||||
**Why it happens:** Kimai's form system (`KimaiFormSelect`, `KimaiFormPlugin`) is tightly coupled to Kimai's JS plugin container and form lifecycle. The cascading logic in `_activateApiSelects` registers a delegated `change` event listener on `document`, but the handler calls `this.getContainer().getPlugin('api')` which requires the KimaiFormSelect instance to have been created by Kimai's app initialization. Our widget's IIFE bundle runs independently.
|
||||
|
||||
**Consequences:** Adding `data-api-url` and `data-related-select` attributes to `<select>` elements in the widget card will silently fail. No errors, just dead selects that do not cascade.
|
||||
|
||||
**Prevention:** Roll your own cascade with plain `fetch()` calls to Kimai's API routes or (better) custom controller endpoints. The widget is already fetch-driven. Three more fetch-based selects is consistent with the existing pattern. Use plain `<select>` elements styled with Tabler CSS. Add TomSelect only if the UX demands it.
|
||||
|
||||
**Detection:** Change the customer picker and observe whether the project picker updates. If it does not, you hit this.
|
||||
|
||||
### Pitfall 2: Destroying and Recreating SVG Without Cleaning Up Tooltips
|
||||
|
||||
**What goes wrong:** The current `renderHeatmap()` starts with `container.innerHTML = ''` (line 184) but tooltips are appended to `document.body` (line 267). When switching visualization modes (year -> week -> day), each render creates a new tooltip. The existing cleanup at line 263 (`document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove())`) only runs inside `renderHeatmap` -- if new modes have their own render functions without this cleanup, tooltips accumulate.
|
||||
|
||||
**Why it happens:** The tooltip is necessarily outside the SVG container (fixed positioning to escape overflow clipping). Each render function must know to clean up the shared tooltip.
|
||||
|
||||
**Consequences:** Multiple `.heatmap-tooltip` divs on the page. Stale tooltip positioning. Memory leaks on repeated mode switches.
|
||||
|
||||
**Prevention:** Extract tooltip into a shared module that all visualization modes import. One tooltip div, created on first use, reused across mode switches. A single `cleanup()` function that any render path calls before rebuilding.
|
||||
|
||||
**Detection:** Switch modes 5+ times, inspect `document.body` children for orphaned `.heatmap-tooltip` elements.
|
||||
|
||||
### Pitfall 3: Hour-Level Aggregation Query Performance
|
||||
|
||||
**What goes wrong:** Day-mode and combined day/hour views need time-of-day data. The current `getDailyAggregation()` groups by `DATE(t.date)` which benefits from Kimai's date index. An hour-level query (`HOUR(t.begin)` or `EXTRACT(HOUR FROM t.begin)`) applies a function to the column, defeating index usage. GROUP BY HOUR over a full year of data scans every row for that user.
|
||||
|
||||
**Why it happens:** MySQL/MariaDB cannot use an index when a function wraps the indexed column.
|
||||
|
||||
**Consequences:** Slow queries for users with thousands of entries. For personal use (likely <10K entries) it is noticeable latency (~200-500ms), not catastrophic. But it makes mode switching feel sluggish.
|
||||
|
||||
**What goes wrong:** Kimai 2.x has changed its internal plugin API, event system, and widget rendering between releases without a formal deprecation cycle. A plugin built against one version silently breaks on the next. The `WidgetInterface`, dashboard rendering hooks, and Twig extensions have all shifted.
|
||||
**Why it happens:** Kimai is maintained primarily by one developer (Kevin Papst). The plugin API is not versioned independently from the application -- it evolves with Kimai core.
|
||||
**Consequences:** Plugin stops rendering, throws Symfony container errors, or silently disappears from the dashboard after a Kimai update.
|
||||
**Prevention:**
|
||||
- Pin to a specific Kimai version in composer.json (`"kimai/kimai": "^2.x"` with a tight constraint)
|
||||
- Read UPGRADING.md in the Kimai repo before each Kimai update
|
||||
- Write an integration test that boots the Symfony kernel with the plugin loaded -- this catches container/DI breakage immediately
|
||||
- Subscribe to Kimai releases (GitHub watch) for breaking change awareness
|
||||
**Detection:** Dashboard widget silently missing after update. Symfony cache clear errors. DI container compilation failures.
|
||||
**Confidence:** MEDIUM (based on Kimai's development history through early 2025)
|
||||
**Phase relevance:** Phase 1 (scaffold) -- lock version constraints early.
|
||||
1. Limit hour-level queries to a narrow date range (one week or one month, not a full year). The UI for day-mode should show a specific date range anyway.
|
||||
2. For the combined matrix, fetch raw timesheet entries for the selected range and aggregate in PHP rather than doing a complex GROUP BY.
|
||||
3. Profile with `EXPLAIN` on the actual query against the dev database.
|
||||
|
||||
### Pitfall 2: Timezone Mismatch Between PHP and JavaScript
|
||||
|
||||
**What goes wrong:** Kimai stores timestamps in UTC in the database. The PHP backend converts to the user's configured timezone for display. If your d3.js heatmap receives raw UTC timestamps and bins them into days client-side, days land in the wrong cells. A session logged at 23:30 Berlin time (21:30 UTC) shows up on the correct day in Kimai's timesheet but the previous day in your heatmap.
|
||||
**Why it happens:** The boundary between "server renders timezone-aware data" and "client renders timezone-aware data" is easy to get wrong, especially when aggregating by day.
|
||||
**Consequences:** Hours appear on wrong days. Totals per day are wrong. User loses trust in the widget immediately.
|
||||
**Prevention:**
|
||||
- Aggregate by day on the PHP side using the user's configured Kimai timezone (`$user->getTimezone()`)
|
||||
- Send pre-aggregated `{date: "2026-04-08", hours: 5.5, count: 3}` to the frontend -- never raw timestamps
|
||||
- The d3 heatmap should receive date strings (not timestamps) so no further timezone conversion happens client-side
|
||||
**Detection:** Compare heatmap day totals against Kimai's built-in weekly/monthly reports. Discrepancies = timezone bug.
|
||||
**Confidence:** HIGH (this is a universal time-tracking visualization issue, well-documented across domains)
|
||||
**Phase relevance:** Phase 1 (data layer) -- get the aggregation right before building the visualization.
|
||||
|
||||
### Pitfall 3: Kimai Widget System Assumptions
|
||||
|
||||
**What goes wrong:** Kimai's dashboard widget system expects widgets to implement specific interfaces and register via Symfony service tags. Developers coming from generic Symfony bundle development wire things up as controllers/routes instead of using the widget system, resulting in a working page but not a dashboard widget.
|
||||
**Why it happens:** Kimai's widget system is Kimai-specific, not standard Symfony. Documentation is sparse. Developers cargo-cult from Symfony controller tutorials instead of studying existing Kimai plugins.
|
||||
**Consequences:** You build a standalone page that works at `/my-heatmap` but cannot embed in the Kimai dashboard. Rework required to fit the widget interface.
|
||||
**Prevention:**
|
||||
- Study existing Kimai plugins that provide dashboard widgets (e.g., `kimai/CalendarBundle`, built-in widgets in `src/Widget/`)
|
||||
- Implement `WidgetInterface` (or extend `AbstractWidget`) from the start
|
||||
- Register as a tagged Symfony service: `kimai.widget`
|
||||
- Do not create a standalone controller -- the widget renders within the dashboard's Twig template
|
||||
**Detection:** Widget does not appear on the dashboard. No errors, just absent.
|
||||
**Confidence:** MEDIUM (based on Kimai source structure through 2024-2025)
|
||||
**Phase relevance:** Phase 1 (scaffold) -- get the widget rendering on the dashboard before writing any d3 code.
|
||||
**Detection:** Mode switch to day-view takes noticeably longer than year-view load.
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
### Pitfall 4: d3.js Bundle Size in Kimai's Asset Pipeline
|
||||
### Pitfall 4: Mode State Lost on Filter Change
|
||||
|
||||
**What goes wrong:** Including all of d3.js (~500KB minified) for a calendar heatmap that only needs `d3-scale`, `d3-selection`, `d3-time`, and `d3-time-format`. Kimai uses Webpack Encore for asset compilation, and a full d3 import bloats the plugin's JS bundle unnecessarily.
|
||||
**Why it happens:** `import * as d3 from 'd3'` is the most common tutorial pattern.
|
||||
**Prevention:**
|
||||
- Import only needed d3 modules: `import { select } from 'd3-selection'; import { scaleQuantize } from 'd3-scale';`
|
||||
- This reduces the d3 footprint to ~50-80KB
|
||||
- Verify with Webpack Bundle Analyzer that tree-shaking works
|
||||
**Confidence:** HIGH (well-documented d3 best practice)
|
||||
**Phase relevance:** Phase 2 (visualization) -- set up imports correctly from the first d3 code.
|
||||
**What goes wrong:** User switches to week-mode, then changes the project filter. The filter's fetch callback re-renders the visualization, but defaults back to year-mode because the mode selection is not part of the render state. The current `doRender()` function (line 339-344) always calls `renderHeatmap()` -- the year view.
|
||||
|
||||
### Pitfall 5: Nix + PHP + Composer Dev Environment Complexity
|
||||
**Why it happens:** The current architecture has no shared state object. Mode and filter are independent UI controls that both trigger re-renders, but neither knows about the other's state.
|
||||
|
||||
**What goes wrong:** Getting a working Kimai instance inside a Nix devshell is non-trivial. Kimai requires PHP 8.1+, specific PHP extensions (intl, gd, mbstring, zip, xml), MySQL/MariaDB or SQLite, and Composer. Nix's PHP packaging sometimes has extension version mismatches, and Kimai's `composer install` may try to download packages that conflict with Nix's hermetic approach.
|
||||
**Why it happens:** PHP ecosystem tooling (Composer, PECL extensions) assumes a mutable system. Nix is immutable. These philosophies clash in subtle ways -- e.g., Composer's `post-install-cmd` scripts that try to write to vendor directories, or PHP extensions that need specific system libraries.
|
||||
**Consequences:** Days lost fighting the dev environment before writing any plugin code. Temptation to abandon Nix and use Docker instead.
|
||||
**Prevention:**
|
||||
- Use `pkgs.php83` (or latest) with extensions via `php.withExtensions`
|
||||
- Use SQLite for the dev database (simpler than spinning up MariaDB in Nix)
|
||||
- Consider a hybrid approach: Nix provides PHP + Composer + Node, but let Composer manage vendor/ normally (don't try to Nixify Composer dependencies)
|
||||
- Set `COMPOSER_HOME` to a writable temp directory
|
||||
- Pre-seed the database with a SQL fixture, not Kimai's interactive installer
|
||||
- Test the devshell setup as the very first task -- do not proceed to plugin code until `bin/console kimai:version` works
|
||||
**Detection:** `nix develop` fails to start, or Kimai throws "extension missing" errors at runtime.
|
||||
**Confidence:** MEDIUM (based on general Nix+PHP experience; Kimai-specific Nix setup is uncommon)
|
||||
**Phase relevance:** Phase 0 / Pre-phase -- this blocks everything else. Must be solved first.
|
||||
**Consequences:** User frustration. Every filter change resets the view mode. Feels broken.
|
||||
|
||||
### Pitfall 6: d3.js SVG Performance with 365+ Day Cells
|
||||
**Prevention:** Introduce a state object BEFORE adding the first new mode:
|
||||
```typescript
|
||||
interface WidgetState {
|
||||
mode: 'year' | 'week' | 'day' | 'combined';
|
||||
metric: 'hours' | 'count';
|
||||
filters: { customerId: number | null; projectId: number | null; activityId: number | null };
|
||||
data: HeatmapData | null;
|
||||
}
|
||||
```
|
||||
All UI actions (mode switch, filter change, metric toggle) update state, then call a single `render(state)` dispatcher that picks the right visualization function.
|
||||
|
||||
**What goes wrong:** Rendering 365 individual `<rect>` elements in SVG is fine. But adding tooltips, hover effects, and click handlers to each cell via d3's `.on()` creates 365+ event listeners. Combined with CSS transitions, this can cause jank on lower-end machines or when the widget shares the dashboard with other widgets.
|
||||
**Why it happens:** The naive approach of binding events per-element is the d3 tutorial default.
|
||||
**Prevention:**
|
||||
- Use event delegation: attach a single mousemove/click listener to the SVG container, use `document.elementFromPoint()` or d3's `pointer()` + geometric lookup to identify the hovered cell
|
||||
- Alternatively, for 365 cells, individual listeners are actually fine in modern browsers -- only becomes a real problem at 1000+ elements. So: measure first, optimize only if dashboard feels sluggish
|
||||
- Avoid CSS `transition` on all 365 rects simultaneously (e.g., on theme change)
|
||||
**Detection:** Dashboard sluggishness. DevTools Performance tab shows long "Recalculate Style" times.
|
||||
**Confidence:** HIGH (well-understood SVG performance characteristics)
|
||||
**Phase relevance:** Phase 2 (visualization) -- implement event delegation from the start if supporting multi-year views, otherwise defer optimization.
|
||||
**Phase warning:** This refactor MUST happen before adding any new visualization mode. Retrofitting state management after multiple modes exist is painful and error-prone.
|
||||
|
||||
### Pitfall 7: Kimai's Webpack Encore Integration for Plugins
|
||||
### Pitfall 5: Cascading API Response Format Assumptions
|
||||
|
||||
**What goes wrong:** Kimai plugins must integrate with Kimai's existing Webpack Encore setup, not ship their own. Developers create a standalone webpack.config.js, build separately, and the assets don't load because Kimai's asset manifest doesn't know about them.
|
||||
**Why it happens:** Symfony Encore documentation describes standalone setup. Kimai's plugin asset pipeline is plugin-specific and less documented.
|
||||
**Prevention:**
|
||||
- Follow Kimai's plugin asset conventions: assets go in `Resources/public/` or use `assets/` with an `encore` entry
|
||||
- Study how existing Kimai plugins (e.g., CustomCSS, ExpensesBundle) register their JS/CSS
|
||||
- Kimai 2.x may use its own asset inclusion mechanism via Twig blocks in widget templates rather than Encore entries -- verify against the version you target
|
||||
- For d3.js, consider whether you even need Encore: a single pre-built JS file in `Resources/public/` that the widget template includes via `<script>` may be simpler and more maintainable than wiring into Encore
|
||||
**Detection:** JavaScript console shows 404 for your JS file. Widget renders but heatmap area is blank.
|
||||
**Confidence:** MEDIUM (Kimai's asset pipeline has evolved; verify against current version)
|
||||
**Phase relevance:** Phase 1 (scaffold) -- resolve asset loading before writing d3 code.
|
||||
**What goes wrong:** Kimai's `/api/projects?customer=X` returns objects with `parentTitle` (customer name), `id`, `name`, and more. Activities can be "global" (no project parent). If you build your own cascade, you must handle this format correctly -- or your own simplified format if you use custom controller endpoints.
|
||||
|
||||
**Why it happens:** Kimai's API response shape is designed for `KimaiFormSelect._updateSelect()` which groups by `parentTitle` into optgroups. Rolling your own cascade means either parsing the same format or defining your own.
|
||||
|
||||
**Prevention:** Use custom controller endpoints (like the existing `getUserProjects()`) that return a simple `[{id, name}]` format. This avoids parsing Kimai's complex API response structure with optgroups and `parentTitle`. Handle edge cases:
|
||||
- Customer with no projects -> empty project list
|
||||
- Project with no activities -> empty activity list
|
||||
- Global activities (no project association) -> include them when no project filter is set
|
||||
|
||||
### Pitfall 6: Color Scale Domain Mismatch Across Modes
|
||||
|
||||
**What goes wrong:** Year-mode uses `max(data.days, d => d.hours)` as the scale ceiling (typically 8-12). Week-mode aggregates by day-of-week over a year (totals could be 200+). Day-mode shows hour slots (0-3 range). Reusing one color scale makes week-mode cells all dark and day-mode cells all light.
|
||||
|
||||
**Why it happens:** Each mode has fundamentally different value ranges. Easy to forget when the scale is set up once in shared code.
|
||||
|
||||
**Prevention:** Each mode computes its own color scale domain from its own data. Extract scale creation into a factory function that accepts a data array. Never share a global scale instance.
|
||||
|
||||
### Pitfall 7: TomSelect Bundle Size Duplication
|
||||
|
||||
**What goes wrong:** Importing `tom-select` in `heatmap.ts` and bundling with esbuild ships a second copy (~50KB min) alongside Kimai's own TomSelect that Webpack Encore already loaded.
|
||||
|
||||
**Why it happens:** The plugin ships a standalone IIFE bundle independent of Kimai's module system. No shared module resolution between them.
|
||||
|
||||
**Prevention:** Do NOT bundle TomSelect. Options:
|
||||
1. **Best for personal use:** Plain `<select>` elements with Tabler CSS. For <50 projects, native browser select works fine. No TomSelect needed.
|
||||
2. **If TomSelect is needed:** Check if `window.TomSelect` exists at runtime (Kimai may expose it globally). Mark as external in esbuild config.
|
||||
3. **Fallback:** Accept the duplication if autocomplete search is essential and Kimai does not expose a global.
|
||||
|
||||
**Recommendation:** Start with plain selects. Add TomSelect only if the list sizes demand it.
|
||||
|
||||
### Pitfall 8: Kimai API Auth from Widget Context
|
||||
|
||||
**What goes wrong:** If the cascade pickers call Kimai's standard API routes (`/api/customers`, `/api/projects`), those routes may require API token authentication depending on Kimai configuration.
|
||||
|
||||
**Why it happens:** Dashboard widgets run in authenticated session context, so session cookies are sent. Kimai's API accepts session auth for browser requests. But some Kimai installations restrict API access to token-only.
|
||||
|
||||
**Prevention:** Add thin controller endpoints in the plugin itself (`/heatmap/customers`, `/heatmap/projects`, `/heatmap/activities`) that call Kimai's repositories directly, exactly like the existing `getUserProjects()` method. This avoids API auth concerns entirely and lets you shape the response format.
|
||||
|
||||
**Detection:** 401 responses when cascade pickers try to fetch data. Test by calling `/api/customers` from browser console on the dashboard page.
|
||||
|
||||
## Minor Pitfalls
|
||||
|
||||
### Pitfall 8: Color Scale Not Adapting to Kimai Themes
|
||||
### Pitfall 9: ResizeObserver vs Window Resize
|
||||
|
||||
**What goes wrong:** Hard-coding heatmap colors (e.g., GitHub's green palette) that clash with Kimai's dark mode or custom themes. The heatmap looks fine in default theme but illegible in dark mode.
|
||||
**Why it happens:** GitHub's green heatmap palette is the default in every tutorial. Kimai supports theme switching.
|
||||
**Prevention:**
|
||||
- Read Kimai's CSS custom properties / SCSS variables for primary/accent colors
|
||||
- Build the color scale from CSS variables: `getComputedStyle(document.documentElement).getPropertyValue('--primary-color')`
|
||||
- Use luminance-based interpolation (light background to saturated primary) rather than a fixed green palette
|
||||
- Test in both light and dark Kimai themes
|
||||
**Detection:** Visual review in dark mode -- colors wash out or become invisible.
|
||||
**Confidence:** HIGH (standard theming concern)
|
||||
**Phase relevance:** Phase 2 (visualization) -- implement theme-aware colors from the start.
|
||||
**What goes wrong:** Current code uses `window.addEventListener('resize')`. Different modes have different natural widths (week-mode is much narrower than year-mode). Kimai's sidebar toggle changes container width without a window resize event.
|
||||
|
||||
### Pitfall 9: Missing Empty State Handling
|
||||
**Prevention:** Use `ResizeObserver` on the container element instead. It fires on any container size change regardless of cause.
|
||||
|
||||
**What goes wrong:** New users or users with sparse data see a completely blank widget. No indication that the widget is working -- looks broken.
|
||||
**Why it happens:** Developers test with seeded data and never encounter the empty state.
|
||||
**Prevention:**
|
||||
- Show an empty state message: "No time entries in this period"
|
||||
- Handle partial data gracefully: a year view where only 2 months have data should still render the full year grid with empty (but visible) cells
|
||||
- Include the empty state in test fixtures
|
||||
**Detection:** Install plugin on a fresh Kimai instance with no time entries.
|
||||
**Confidence:** HIGH (universal UX concern)
|
||||
**Phase relevance:** Phase 2 (visualization) -- implement alongside the heatmap.
|
||||
### Pitfall 10: Entry Count Toggle Is Not Just a CSS Swap
|
||||
|
||||
### Pitfall 10: Clicking Day Cell -- Kimai URL Structure Assumptions
|
||||
**What goes wrong:** The hours/count toggle seems trivial -- just show `d.hours` vs `d.count`. But the color scale domain must change (hours: 0-12 vs count: 0-20+), tooltips must show different labels, and stats must recalculate.
|
||||
|
||||
**What goes wrong:** Building the "click to navigate to timesheet" URL by guessing Kimai's route structure (`/en/timesheet?date=2026-04-08`). The URL structure depends on Kimai's locale prefix, routing configuration, and filter parameter names, which change between versions.
|
||||
**Why it happens:** Hardcoding URLs instead of using Symfony's router to generate them.
|
||||
**Prevention:**
|
||||
- Generate the timesheet URL on the PHP side using Symfony's `UrlGeneratorInterface` / `router->generate()`
|
||||
- Pass the URL template to JavaScript as a data attribute: `data-timesheet-url-template="/en/timesheet/?daterange={date}~{date}"`
|
||||
- Let JS only do string interpolation on the template, never URL construction
|
||||
**Detection:** Clicking a cell leads to 404 or wrong page. Breaks when Kimai locale changes.
|
||||
**Confidence:** MEDIUM (URL parameter names need verification against current Kimai version)
|
||||
**Phase relevance:** Phase 2/3 (interactivity) -- when implementing click-to-navigate.
|
||||
**Prevention:** Treat the metric toggle as a state change that triggers a full re-render with the appropriate color scale, tooltip format, and stats calculation. The backend already returns both `hours` and `count` in `DayEntry`, so no API changes needed.
|
||||
|
||||
### Pitfall 11: Testing d3.js Output in a Headless Environment
|
||||
### Pitfall 11: Week Start Preference Must Propagate to All Modes
|
||||
|
||||
**What goes wrong:** PHPUnit cannot test JavaScript. Jest/Vitest cannot access a real Kimai DOM. Developers skip frontend tests entirely because "it's just a visualization."
|
||||
**Why it happens:** d3.js renders SVG in the DOM. Testing SVG output requires either a DOM environment (jsdom) or snapshot testing. Neither feels natural.
|
||||
**Prevention:**
|
||||
- Use jsdom with Vitest to test d3 rendering: create a container, run your heatmap function, assert SVG structure (number of rects, correct date attributes, color classes)
|
||||
- Separate data transformation (pure functions: input dates/hours, output cell data) from rendering (d3 DOM manipulation). Test transformations with plain unit tests -- these catch the important bugs
|
||||
- Snapshot test the SVG output for regression detection, but keep snapshots small (test one month, not a full year)
|
||||
**Detection:** Heatmap breaks after refactoring with no test catching it.
|
||||
**Confidence:** HIGH (standard d3 testing approach)
|
||||
**Phase relevance:** Phase 1 (TDD setup) -- establish the test infrastructure before writing d3 code.
|
||||
**What goes wrong:** Year-mode respects `data-week-start` (monday/sunday). Week-mode aggregation (day-of-week buckets) must also respect this -- Monday-first vs Sunday-first changes which column is "day 0". Day-mode hour bucketing is unaffected but the combined day/hour matrix is.
|
||||
|
||||
**Prevention:** Include `weekStart` in the state object passed to all visualization modes. Test both configurations explicitly in Vitest.
|
||||
|
||||
### Pitfall 12: Multiple Render Functions Diverging Over Time
|
||||
|
||||
**What goes wrong:** Starting with one `renderHeatmap()` and adding `renderWeekMode()`, `renderDayMode()`, `renderCombinedMode()` as separate functions. Over time, bug fixes (tooltip positioning, accessibility, theme colors) get applied to one function but not others.
|
||||
|
||||
**Prevention:** Extract shared concerns (tooltip management, color scale creation, cell click handling, stats rendering) into utility functions that all modes use. Each mode only implements its unique layout logic (grid generation, axis labels, cell positioning).
|
||||
|
||||
## Phase-Specific Warnings
|
||||
|
||||
| Phase Topic | Likely Pitfall | Mitigation |
|
||||
|-------------|---------------|------------|
|
||||
| Dev environment (Phase 0) | Nix+PHP+Kimai setup eats days (Pitfall 5) | Timebox to 1 day. Fall back to Docker if stuck. |
|
||||
| Scaffold (Phase 1) | Widget not appearing on dashboard (Pitfall 3) | Study existing plugins first. Get empty widget visible before anything else. |
|
||||
| Scaffold (Phase 1) | Asset loading fails (Pitfall 7) | Start with inline `<script>` in Twig, migrate to proper assets after it works. |
|
||||
| Data layer (Phase 1) | Timezone-wrong day aggregation (Pitfall 2) | Aggregate in PHP, send date strings. Write test comparing against Kimai's own report. |
|
||||
| Visualization (Phase 2) | Hard-coded colors break in dark mode (Pitfall 8) | Use CSS variables from day one. |
|
||||
| Visualization (Phase 2) | Full d3 import bloats bundle (Pitfall 4) | Import specific modules only. |
|
||||
| Interactivity (Phase 2-3) | Hardcoded timesheet URLs break (Pitfall 10) | Generate URL template server-side. |
|
||||
| Maintenance (ongoing) | Kimai update breaks plugin (Pitfall 1) | Pin version, integration test that boots kernel. |
|
||||
| State management refactor | Pitfall 4 (mode state lost on filter) | Implement state object BEFORE adding any new mode |
|
||||
| Entity pickers / cascade | Pitfall 1 (KimaiFormSelect needs form lifecycle) | Roll your own cascade with fetch + plain selects |
|
||||
| Entity pickers / cascade | Pitfall 7 (TomSelect bundle duplication) | Start with plain selects, no TomSelect |
|
||||
| Cascade API endpoints | Pitfall 5 (API response format), Pitfall 8 (auth) | Own controller endpoints returning simple format |
|
||||
| Week/day modes | Pitfall 2 (tooltip cleanup), Pitfall 6 (color scale per mode) | Shared tooltip manager, per-mode scale factory |
|
||||
| Day/hour visualization | Pitfall 3 (hour aggregation query perf) | Narrow date range for hour-level queries |
|
||||
| Hours/count toggle | Pitfall 10 (not just CSS) | Full re-render with different scale and stats |
|
||||
| All new modes | Pitfall 11 (week start), Pitfall 12 (code divergence) | Shared utilities, weekStart in state, test both configs |
|
||||
|
||||
## Sources
|
||||
|
||||
- Kimai documentation at kimai.org/documentation (not fetched -- web tools unavailable)
|
||||
- Kimai GitHub repository structure and plugin examples (based on training data, not live verification)
|
||||
- d3.js modular import documentation (d3js.org)
|
||||
- General Symfony bundle development practices
|
||||
- Nix PHP packaging ecosystem knowledge
|
||||
|
||||
**Confidence note:** All findings are based on training data (cutoff ~early 2025). Kimai's plugin API, asset pipeline, and widget system should be verified against the current Kimai release before implementation begins. The Kimai ecosystem moves fast and is primarily one-developer-driven, so APIs can shift without extended deprecation periods.
|
||||
- `dev/kimai/assets/js/forms/KimaiFormSelect.js` lines 386-447: cascade logic via `_activateApiSelects`, `getContainer().getPlugin('api')` dependency at line 433
|
||||
- `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php`: wires `data-api-url`, `data-related-select`, `data-empty-url` onto Symfony EntityType form views only
|
||||
- `dev/kimai/src/Form/Type/ProjectType.php` lines 124-137: `api_data` option configures activity cascade route
|
||||
- `assets/src/heatmap.ts`: tooltip on `document.body` (line 267), `innerHTML` cleanup (line 184), resize handler (lines 392-398), `doRender` always calls year-view (line 341)
|
||||
- `src/Service/HeatmapService.php`: daily aggregation via `DATE(t.date)` GROUP BY (lines 24-31)
|
||||
- `assets/src/types.ts`: `DayEntry` has both `hours` and `count` fields
|
||||
- `.claude/projects/.../memory/project_kimai_lessons.md`: Phase 1-2 lessons on DI, widget system, testing
|
||||
|
|
|
|||
|
|
@ -1,255 +1,179 @@
|
|||
# Technology Stack
|
||||
# Technology Stack: v1.1 Additions
|
||||
|
||||
**Project:** Kimai Heatmap Plugin
|
||||
**Project:** Kimai Heatmap Plugin v1.1
|
||||
**Researched:** 2026-04-08
|
||||
**Note:** WebSearch/WebFetch unavailable. Recommendations based on training data (cutoff May 2025). Versions should be verified against current Kimai releases before starting development.
|
||||
**Scope:** NEW stack additions only. Existing validated stack (PHP 8.2, Symfony 6.4, d3 v7, TypeScript, esbuild, Vitest, PHPUnit) is not re-evaluated.
|
||||
|
||||
## Recommended Stack
|
||||
## New Dependencies
|
||||
|
||||
### Core Framework (Kimai Plugin)
|
||||
### TomSelect (Entity Pickers)
|
||||
|
||||
| Technology | Version | Purpose | Why | Confidence |
|
||||
|------------|---------|---------|-----|------------|
|
||||
| PHP | 8.2+ | Plugin runtime | Kimai 2.x requires PHP 8.1 minimum; 8.2 for current features and performance. Verify against Kimai's composer.json. | MEDIUM |
|
||||
| Symfony | 6.4 LTS | Bundle framework | Kimai 2.x is built on Symfony 6.4 LTS. Plugins must match the host Symfony version exactly. | MEDIUM |
|
||||
| Kimai | 2.x (latest) | Host application | Target the current stable release. Check github.com/kimai/kimai/releases before starting. | MEDIUM |
|
||||
| tom-select | ^2.4.3 | Customer/project/activity cascading pickers | Kimai uses TomSelect 2.4.3 for all its entity pickers. Matching the version ensures visual consistency with Kimai's existing selects. ~16KB gzipped complete, ~12KB base. | HIGH |
|
||||
| @types/tom-select | latest | TypeScript definitions | Type safety for TomSelect API calls (constructor options, instance methods like `clear()`, `sync()`, `destroy()`). | HIGH |
|
||||
|
||||
### Visualization
|
||||
**Why bundle TomSelect ourselves instead of reusing Kimai's instance:**
|
||||
|
||||
| Technology | Version | Purpose | Why | Confidence |
|
||||
|------------|---------|---------|-----|------------|
|
||||
| d3 | ^7.9 | Core visualization library | d3 v7 is the current stable. ESM-native, tree-shakeable. Use specific sub-modules, not the full bundle. | HIGH |
|
||||
| d3-scale | ^4.0 | Color and position scales | Needed for mapping hours/counts to color intensities and day positions. | HIGH |
|
||||
| d3-selection | ^3.0 | DOM manipulation | Core d3 pattern for binidng data to SVG elements. | HIGH |
|
||||
| d3-time | ^3.1 | Date calculations | Week/day grid layout calculations for the calendar. | HIGH |
|
||||
| d3-time-format | ^4.1 | Date formatting | Tooltip and axis labels. | HIGH |
|
||||
| d3-scale-chromatic | ^3.1 | Color schemes | Provides sequential color scales (Greens, Blues) as starting points before mapping to Kimai theme vars. | HIGH |
|
||||
| d3-shape | ^3.2 | Rect generation | For the heatmap cell rectangles. | HIGH |
|
||||
Kimai bundles TomSelect inside its Webpack Encore build (`app` entry point). It is NOT exposed as a `window` global. Our plugin ships as a standalone IIFE via esbuild, loaded separately from Kimai's bundle. There is no way to import from Kimai's Webpack chunks at runtime.
|
||||
|
||||
**Do NOT use:**
|
||||
- `cal-heatmap` or other d3-wrapper heatmap libraries: They add abstraction over d3 that limits customization (Kimai theme integration, click-to-navigate, toggle modes). Rolling your own with raw d3 modules is straightforward for a calendar heatmap and gives full control.
|
||||
- `d3` full bundle import: Import only the sub-modules you need. Keeps the asset small and avoids polluting the Kimai frontend.
|
||||
Verified by examining: `dev/kimai/webpack.config.js` (no `externals` or global exposure), `dev/kimai/assets/js/forms/KimaiFormSelect.js` (imports TomSelect as ESM module), and `dev/kimai/assets/js/KimaiLoader.js` (all form plugins are internal to the Kimai container system).
|
||||
|
||||
### Testing
|
||||
**CSS consideration:** Kimai already loads TomSelect's CSS via its Sass pipeline. Our TomSelect instances will inherit Kimai's existing `.ts-wrapper`, `.ts-control`, `.ts-dropdown` styles automatically. We do NOT need to bundle TomSelect CSS -- only the JS.
|
||||
|
||||
| Technology | Version | Purpose | Why | Confidence |
|
||||
|------------|---------|---------|-----|------------|
|
||||
| PHPUnit | ^10.5 or ^11.0 | Backend tests | Kimai uses PHPUnit for its own tests. Match the version Kimai ships with in its dev dependencies. | MEDIUM |
|
||||
| Vitest | ^3.0 | JS heatmap tests | Fast, ESM-native, works with d3's ESM modules out of the box. Jest struggles with ESM d3 imports without transformation config. | HIGH |
|
||||
| jsdom | (via vitest) | DOM environment | Vitest's jsdom environment provides enough DOM for d3 selection/rendering tests without a browser. | HIGH |
|
||||
**Import strategy:** Use `tom-select/dist/js/tom-select.complete` (includes change_listener plugin needed for cascading) or cherry-pick `tom-select/src/tom-select` + specific plugins. The complete bundle is fine at 16KB gzipped given we already bundle d3 modules.
|
||||
|
||||
**Do NOT use:**
|
||||
- `Jest` for JS tests: d3 v7 is ESM-only. Jest's ESM support requires `--experimental-vm-modules` and transform config. Vitest handles ESM natively.
|
||||
- `Cypress`/`Playwright` for the heatmap: Overkill for a single widget. SVG output assertions via jsdom + snapshot testing covers the rendering. Save E2E for integration testing against a running Kimai instance if needed later.
|
||||
### No New d3 Modules Needed
|
||||
|
||||
### Development Environment
|
||||
The existing d3 dependencies are sufficient for all four visualization modes:
|
||||
|
||||
| Technology | Version | Purpose | Why | Confidence |
|
||||
|------------|---------|---------|-----|------------|
|
||||
| Nix flake | - | Reproducible dev env | Matches Toph's NixOS infra. Provides PHP, Node, Composer, and a local Kimai instance. | HIGH |
|
||||
| Composer | ^2.7 | PHP dependency management | Standard for Symfony/Kimai. | HIGH |
|
||||
| npm | ^10.x | JS dependency management | For d3 modules and Vitest. Simpler than yarn/pnpm for a single-widget plugin. | HIGH |
|
||||
| esbuild | ^0.24 | JS bundling | Bundle d3 modules into a single file for Kimai's asset pipeline. Faster than webpack, simpler config, handles ESM natively. | HIGH |
|
||||
| Mode | Layout Approach | d3 Modules Used |
|
||||
|------|----------------|-----------------|
|
||||
| Year (existing) | Week columns x 7 day rows | d3-selection, d3-scale, d3-time, d3-time-format, d3-array |
|
||||
| Week | 7 columns (Mon-Sun), single row of aggregated values | d3-selection, d3-scale, d3-array |
|
||||
| Day (time-of-day) | 24 columns (hours), 7 rows (days-of-week) | d3-selection, d3-scale, d3-array |
|
||||
| Combined (day/hour) | Date columns x 24 hour rows, or 7 day-of-week columns x 24 hour rows | d3-selection, d3-scale, d3-array, d3-time |
|
||||
|
||||
**Do NOT use:**
|
||||
- `Webpack Encore`: Kimai's own frontend uses it, but for a plugin shipping a single JS file, esbuild is simpler. One build command, no Symfony Encore config to maintain.
|
||||
- `Docker` for dev: The project spec calls for Nix flake. Docker would work but adds friction on NixOS and doesn't match the user's infrastructure.
|
||||
All modes render SVG rect grids -- the same pattern as the year view. The difference is in data aggregation (backend) and grid layout math (frontend), not in d3 capabilities.
|
||||
|
||||
### Database
|
||||
`d3-shape` (listed in v1.0 STACK.md) was never added to package.json and is not needed. Rectangles are drawn with `rect` elements via d3-selection, not d3-shape.
|
||||
|
||||
No additional database needed. The plugin reads from Kimai's existing `kimai2_timesheet` table via Kimai's `TimesheetRepository` or a custom DQL query. No migrations required.
|
||||
## Backend Additions
|
||||
|
||||
## Kimai Plugin Structure
|
||||
### New Query Aggregations
|
||||
|
||||
This is the critical structural knowledge. A Kimai plugin is a Symfony bundle installed in `var/plugins/` (development) or via Composer (distribution).
|
||||
The existing `HeatmapService::getDailyAggregation()` groups by `DATE(t.date)`. New modes need:
|
||||
|
||||
### Directory Layout
|
||||
| Mode | SQL Aggregation | New Method |
|
||||
|------|----------------|------------|
|
||||
| Week (day-of-week) | `GROUP BY DAYOFWEEK(t.date)` | `getWeekdayAggregation()` |
|
||||
| Day (time-of-day) | `GROUP BY HOUR(t.begin)` | `getHourlyAggregation()` |
|
||||
| Combined | `GROUP BY DAYOFWEEK(t.date), HOUR(t.begin)` | `getDayHourAggregation()` |
|
||||
|
||||
```
|
||||
KimaiHeatmapBundle/
|
||||
KimaiHeatmapBundle.php # Bundle class, extends PluginInterface
|
||||
DependencyInjection/
|
||||
KimaiHeatmapExtension.php # Loads services config
|
||||
Resources/
|
||||
config/
|
||||
services.yaml # Service definitions
|
||||
views/
|
||||
widget/
|
||||
heatmap.html.twig # Widget template
|
||||
public/
|
||||
heatmap.js # Bundled d3 heatmap (esbuild output)
|
||||
heatmap.css # Widget styles
|
||||
EventSubscriber/
|
||||
DashboardSubscriber.php # Registers widget on dashboard
|
||||
Widget/
|
||||
HeatmapWidget.php # Widget class implementing WidgetInterface
|
||||
Repository/
|
||||
HeatmapRepository.php # Data aggregation queries
|
||||
composer.json # Package metadata
|
||||
package.json # JS dependencies (d3, vitest)
|
||||
esbuild.config.mjs # JS build config
|
||||
assets/
|
||||
src/
|
||||
heatmap.ts # Source d3 heatmap code
|
||||
test/
|
||||
heatmap.test.ts # Vitest tests
|
||||
tests/
|
||||
Widget/
|
||||
HeatmapWidgetTest.php # PHPUnit tests
|
||||
Repository/
|
||||
HeatmapRepositoryTest.php # PHPUnit tests
|
||||
```
|
||||
These are Doctrine DQL queries using the same `TimesheetRepository` and `QueryBuilder` pattern as the existing method. No new PHP packages needed.
|
||||
|
||||
**Confidence: MEDIUM** -- This structure follows Kimai's documented plugin patterns as of my training data. Verify against the current plugin developer guide and existing plugins like `ExpensesBundle` or `CustomContentBundle` on GitHub.
|
||||
**Note:** Kimai stores `t.begin` (start time) and `t.end` (end time) on timesheet entries. For hourly breakdown, use `HOUR(t.begin)` to assign each entry to its starting hour. Multi-hour entries will be attributed to their start hour for simplicity; splitting across hours would require duration-proportional allocation (complex, defer to future).
|
||||
|
||||
### Bundle Registration
|
||||
### New Controller Endpoints
|
||||
|
||||
```php
|
||||
// KimaiHeatmapBundle.php
|
||||
namespace KimaiPlugin\KimaiHeatmapBundle;
|
||||
The existing `HeatmapController::data()` endpoint needs:
|
||||
- `mode` query param (`year|week|day|combined`, default `year`)
|
||||
- `customer` query param (for cascading filter support)
|
||||
- `activity` query param (for activity filtering)
|
||||
|
||||
use App\Plugin\PluginInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
These are additions to the existing controller, not new bundles or packages.
|
||||
|
||||
class KimaiHeatmapBundle extends Bundle implements PluginInterface
|
||||
{
|
||||
}
|
||||
```
|
||||
### Entity Cascade Endpoints
|
||||
|
||||
Kimai auto-discovers bundles in `var/plugins/` by scanning for classes implementing `PluginInterface`. No manual kernel registration needed.
|
||||
**Decision: Use our own endpoints, NOT Kimai's API routes.**
|
||||
|
||||
### Dashboard Widget Registration
|
||||
Kimai's API routes (`get_customers`, `get_projects`, `get_activities`) are guarded by `#[IsGranted('API')]`, which requires the user to have the API permission. Dashboard widget users may not have API access. Additionally, Kimai's cascading logic lives in `KimaiFormSelect.js` which depends on `KimaiContainer.getPlugin('api')` -- the entire Kimai plugin system that our standalone widget cannot access.
|
||||
|
||||
Kimai uses an event-based widget system. You implement `WidgetInterface` and subscribe to the dashboard event.
|
||||
Instead, add lightweight endpoints to our own `HeatmapController`:
|
||||
- `GET /heatmap/customers` -- customers the user has timesheet entries for
|
||||
- `GET /heatmap/projects?customer={id}` -- projects filtered by customer
|
||||
- `GET /heatmap/activities?project={id}` -- activities filtered by project
|
||||
|
||||
```php
|
||||
// Widget/HeatmapWidget.php
|
||||
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
|
||||
These use `IS_AUTHENTICATED_REMEMBERED` + `view_own_timesheet` (same as our existing data endpoint) and query only entities the user has actually tracked time against. This is better UX anyway -- no empty customers/projects cluttering the pickers.
|
||||
|
||||
use App\Widget\Type\AbstractWidget;
|
||||
use App\Widget\WidgetInterface;
|
||||
## UI Additions
|
||||
|
||||
class HeatmapWidget extends AbstractWidget
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'Activity Heatmap';
|
||||
}
|
||||
### Mode Switcher
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return '@KimaiHeatmap/widget/heatmap.html.twig';
|
||||
}
|
||||
Use Tabler's `btn-group` (segmented control) for mode switching. Tabler is Kimai's UI framework and is already loaded. No additional CSS or JS framework needed.
|
||||
|
||||
public function getData(array $options = []): mixed
|
||||
{
|
||||
// Query timesheet data, aggregate by day
|
||||
// Return array of [date => hours/count]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Confidence: MEDIUM** -- Widget API may have changed. Check `App\Widget\Type\AbstractWidget` and `App\Widget\WidgetInterface` in current Kimai source.
|
||||
|
||||
### Twig Template Pattern
|
||||
|
||||
```twig
|
||||
{# Resources/views/widget/heatmap.html.twig #}
|
||||
{% extends '@theme/widget.html.twig' %}
|
||||
|
||||
{% block widget_content %}
|
||||
<div id="kimai-heatmap"
|
||||
data-entries="{{ widget.data|json_encode }}"
|
||||
data-base-url="{{ path('timesheet') }}">
|
||||
```html
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary active">Year</button>
|
||||
<button class="btn btn-sm btn-outline-primary">Week</button>
|
||||
<button class="btn btn-sm btn-outline-primary">Day</button>
|
||||
<button class="btn btn-sm btn-outline-primary">Combined</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block widget_javascript %}
|
||||
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Pass data as a JSON data attribute. The JS reads it and renders the SVG. This avoids API calls and works with Kimai's server-side rendering model.
|
||||
### Display Toggle (Hours vs Entry Count)
|
||||
|
||||
**Confidence: LOW** -- The exact Twig block names and asset path conventions should be verified against a working Kimai plugin. The `@theme/widget.html.twig` base template may differ.
|
||||
Same pattern -- `btn-group` or a simple toggle. The data is already in `DayEntry` (`hours` and `count` fields). This is purely a frontend change to switch which field drives the color scale.
|
||||
|
||||
## Alternatives Considered
|
||||
## What NOT to Add
|
||||
|
||||
| Category | Recommended | Alternative | Why Not |
|
||||
|----------|-------------|-------------|---------|
|
||||
| JS Visualization | d3 sub-modules | cal-heatmap | Limits customization for Kimai theme integration, click navigation, and mode toggling |
|
||||
| JS Visualization | d3 sub-modules | Chart.js matrix | Less control over calendar layout, weaker SVG customization |
|
||||
| JS Bundler | esbuild | Webpack Encore | Overkill for bundling a single widget's JS. Encore adds Symfony config overhead. |
|
||||
| JS Testing | Vitest | Jest | d3 v7 is ESM-only; Jest's ESM support requires workarounds |
|
||||
| JS Language | TypeScript | Plain JS | Type safety for the d3 code, catches data shape mismatches at build time |
|
||||
| PHP Testing | PHPUnit | Pest | Kimai's own tests use PHPUnit. Consistency with the host app matters. |
|
||||
| Dev Env | Nix flake | Docker Compose | User runs NixOS; Nix is native. Docker adds a layer. |
|
||||
| Temptation | Why Not |
|
||||
|------------|---------|
|
||||
| `d3-axis` | The new modes don't need formal axes. Simple text labels (like the existing month/day labels) are sufficient and lighter. |
|
||||
| `d3-shape` | We draw rectangles with `<rect>`, not path generators. |
|
||||
| `d3-transition` | Animations between mode switches would be nice but add complexity. Defer to polish. |
|
||||
| `chart.js` or `cal-heatmap` | Same rationale as v1.0 -- raw d3 gives us full control over Kimai theme integration. |
|
||||
| TomSelect CSS bundle | Already loaded by Kimai's Sass pipeline. Bundling it would cause style conflicts. |
|
||||
| Kimai's `KimaiFormSelect.js` | Depends on `KimaiContainer` plugin system. Our widget is standalone IIFE. |
|
||||
| `luxon` or `date-fns` | d3-time and d3-time-format handle all our date math. No need for another date library. |
|
||||
|
||||
## TypeScript for d3
|
||||
## Updated Type Definitions
|
||||
|
||||
Use TypeScript for the heatmap source code. d3 v7 ships with type definitions. TypeScript catches common d3 mistakes (wrong scale types, missing data fields) at build time. esbuild handles `.ts` natively.
|
||||
New types needed in `types.ts`:
|
||||
|
||||
```json
|
||||
// tsconfig.json (minimal)
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["assets/src/**/*.ts", "assets/test/**/*.ts"]
|
||||
```typescript
|
||||
// Visualization modes
|
||||
type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
|
||||
|
||||
// Display metric toggle
|
||||
type DisplayMetric = 'hours' | 'count';
|
||||
|
||||
// Hourly data for day/combined modes
|
||||
interface HourEntry {
|
||||
hour: number; // 0-23
|
||||
dayOfWeek: number; // 0-6 (Monday=0)
|
||||
hours: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Weekday aggregation for week mode
|
||||
interface WeekdayEntry {
|
||||
dayOfWeek: number; // 0-6
|
||||
hours: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Entity picker options
|
||||
interface CustomerOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ActivityOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Installation Commands
|
||||
|
||||
```bash
|
||||
# PHP dependencies (in the plugin directory)
|
||||
composer init --name="kimai/heatmap-bundle" --type="kimai-plugin"
|
||||
# No extra PHP packages needed beyond Kimai's own dependencies
|
||||
# New runtime dependency
|
||||
npm install tom-select@^2.4.3
|
||||
|
||||
# JS dependencies
|
||||
npm init -y
|
||||
npm install d3-scale d3-selection d3-time d3-time-format d3-scale-chromatic d3-shape
|
||||
npm install -D typescript esbuild vitest jsdom @types/d3-scale @types/d3-selection @types/d3-time @types/d3-time-format @types/d3-scale-chromatic @types/d3-shape
|
||||
# New dev dependency (check if types ship with tom-select itself first)
|
||||
npm install -D @types/tom-select
|
||||
```
|
||||
|
||||
## Nix Flake Approach
|
||||
No new PHP/Composer dependencies required.
|
||||
|
||||
The flake should provide:
|
||||
1. **PHP 8.2** with extensions: `mbstring`, `intl`, `pdo_mysql` (or `pdo_sqlite` for dev), `xml`, `zip`
|
||||
2. **Composer 2.x**
|
||||
3. **Node.js 22 LTS** with npm
|
||||
4. **MariaDB** or **SQLite** for the local Kimai DB
|
||||
5. A **devShell** script that clones Kimai, runs migrations, seeds test data, and symlinks the plugin into `var/plugins/`
|
||||
## esbuild Consideration
|
||||
|
||||
SQLite is recommended for local dev to avoid running a separate DB server. Kimai supports SQLite out of the box.
|
||||
TomSelect's package includes CSS files. When importing from `tom-select`, esbuild may try to bundle CSS. Since Kimai already loads TomSelect CSS, import only the JS entry point:
|
||||
|
||||
**Confidence: HIGH** for the Nix approach concept. The specific Kimai setup commands need verification.
|
||||
```typescript
|
||||
import TomSelect from 'tom-select/dist/js/tom-select.complete';
|
||||
```
|
||||
|
||||
## Key Version Verification Checklist
|
||||
|
||||
Before starting development, verify these against current sources:
|
||||
|
||||
- [ ] Kimai latest stable version (check GitHub releases)
|
||||
- [ ] Kimai's required PHP version (check `composer.json`)
|
||||
- [ ] Kimai's Symfony version (check `composer.lock`)
|
||||
- [ ] Kimai's `WidgetInterface` API (check `src/Widget/` in Kimai source)
|
||||
- [ ] Kimai's Twig widget template blocks (check existing dashboard widgets in Kimai source)
|
||||
- [ ] Kimai's asset serving mechanism for plugins (may use Symfony AssetMapper now instead of Encore)
|
||||
- [ ] d3 v7 latest patch version on npm
|
||||
This avoids CSS duplication and style conflicts.
|
||||
|
||||
## Sources
|
||||
|
||||
- Kimai plugin documentation: https://www.kimai.org/documentation/plugin-development.html
|
||||
- Kimai GitHub: https://github.com/kimai/kimai
|
||||
- d3.js documentation: https://d3js.org/
|
||||
- d3 calendar heatmap examples: https://observablehq.com/@d3/calendar
|
||||
- Vitest documentation: https://vitest.dev/
|
||||
- esbuild documentation: https://esbuild.github.io/
|
||||
|
||||
**All sources are from training data, not live-fetched. Confidence levels reflect this limitation.**
|
||||
- Kimai TomSelect integration: `dev/kimai/assets/js/forms/KimaiFormSelect.js` (local source, verified)
|
||||
- Kimai webpack config: `dev/kimai/webpack.config.js` (local source, confirmed no TomSelect global exposure)
|
||||
- Kimai API permissions: `dev/kimai/src/API/ProjectController.php` (local source, `#[IsGranted('API')]`)
|
||||
- Kimai API cascading pattern: `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php` (local source)
|
||||
- Kimai package.json: `dev/kimai/package.json` (local source, `tom-select: ^2.4.3`)
|
||||
- [tom-select on npm](https://www.npmjs.com/package/tom-select) -- v2.5.2 latest, ~16KB gzipped
|
||||
- [tom-select v2.5.2 on Bundlephobia](https://bundlephobia.com/package/tom-select) -- bundle size analysis
|
||||
- [TomSelect documentation](https://tom-select.js.org/)
|
||||
|
|
|
|||
|
|
@ -1,150 +1,161 @@
|
|||
# Project Research Summary
|
||||
|
||||
**Project:** Kimai Heatmap Plugin
|
||||
**Domain:** Time-tracking dashboard widget (Symfony bundle + d3.js visualization)
|
||||
**Project:** Kimai Heatmap Plugin v1.1 (Modes & Filtering)
|
||||
**Domain:** Time-tracking dashboard visualization -- multi-mode heatmap with cascading entity filters
|
||||
**Researched:** 2026-04-08
|
||||
**Confidence:** MEDIUM
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This is a Kimai 2.x plugin that adds a GitHub-style activity heatmap to the dashboard. The product pattern is well-established: calendar grid, color intensity for activity levels, tooltips, click-through navigation. The technology choices are straightforward -- a Symfony bundle for the backend (matching Kimai's framework), d3.js sub-modules for the SVG visualization, TypeScript for type safety, and esbuild for bundling. No additional database is needed; the plugin reads from Kimai's existing timesheet table.
|
||||
v1.1 adds four visualization modes (year, week, day-of-week, day/hour combined matrix), cascading entity pickers (customer/project/activity), and an hours-vs-count display toggle to the existing heatmap widget. The existing stack (PHP 8.2, Symfony 6.4, d3 v7, TypeScript, esbuild) handles everything. The only new dependency is TomSelect for entity pickers -- and even that should be deferred until the final phase. The core work is a rendering architecture refactor (strategy pattern for modes), three new backend aggregation queries, and a state management object to coordinate filters, modes, and display metric.
|
||||
|
||||
The recommended approach is a strict 4-phase build: plugin scaffold first (prove the widget appears on the dashboard), then data layer (aggregation API), then visualization (d3 heatmap rendering), then interactivity (filters, toggles, click navigation). This ordering is dictated by hard dependencies -- you cannot render a heatmap without data, and you cannot build data queries without a working plugin scaffold. The Nix dev environment setup is a prerequisite phase that should be timeboxed to one day.
|
||||
The recommended approach is to refactor the monolithic `renderHeatmap()` into a mode-dispatched renderer system BEFORE adding any new visualization. This is the critical sequencing insight: state management and the renderer interface must exist before the first new mode lands, or every subsequent mode will be a retrofit nightmare. Week-mode (day-of-week aggregation) should come first because it needs zero backend changes -- it aggregates existing daily data client-side -- which validates the renderer architecture cheaply. Entity pickers come last because they are the most complex integration point and everything else works fine with the existing project dropdown.
|
||||
|
||||
The primary risks are: (1) Kimai's plugin API is under-documented and shifts between releases -- the widget interface, DI tags, and asset conventions all need verification against the target Kimai version before writing code; (2) timezone-incorrect day aggregation will produce wrong data silently -- aggregation must happen server-side in the user's configured timezone; (3) the Nix + PHP + Kimai dev environment setup can consume days if not timeboxed. All three are manageable with the mitigations outlined below.
|
||||
The primary risks are: (1) accidentally coupling to Kimai's internal KimaiFormSelect.js system, which silently fails outside Kimai's form lifecycle; (2) tooltip/DOM cleanup leaks when switching between modes; and (3) TomSelect bundle duplication. All three are well-understood and have clear preventions documented in the research. The hour-level aggregation queries (day/combined modes) are the only performance concern -- they defeat index usage -- but for personal use with <10K entries this is acceptable latency, not a blocker.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The plugin is a standard Symfony bundle running inside Kimai 2.x. The frontend is a self-contained d3.js visualization bundled with esbuild and shipped as a single JS file in `Resources/public/`. No integration with Kimai's Webpack Encore build is needed or desired.
|
||||
No new PHP/Composer dependencies. One new npm dependency: `tom-select@^2.4.3` (only when entity pickers are implemented). All four visualization modes use existing d3 sub-modules -- no new d3 packages needed. `d3-shape` was listed in v1.0 research but was never needed and should not be added.
|
||||
|
||||
**Core technologies:**
|
||||
- **PHP 8.2+ / Symfony 6.4 LTS**: Must match Kimai's runtime exactly. Verify against Kimai's `composer.json`.
|
||||
- **d3.js sub-modules** (d3-scale, d3-selection, d3-time, d3-time-format, d3-scale-chromatic, d3-shape): Selective imports keep bundle size to ~50-80KB vs ~500KB for full d3.
|
||||
- **TypeScript**: d3 v7 ships types. Catches data shape bugs at build time. esbuild handles `.ts` natively.
|
||||
- **esbuild**: Single-command bundling of d3 modules into one JS file. Simpler than Webpack Encore for a single widget.
|
||||
- **Vitest + jsdom**: ESM-native testing for d3 code. Jest struggles with d3 v7's ESM-only modules.
|
||||
- **PHPUnit**: Matches Kimai's own test framework.
|
||||
- **Nix flake**: Reproducible dev env with PHP 8.2, Composer, Node 22, SQLite.
|
||||
**New additions only:**
|
||||
- **TomSelect ^2.4.3**: Cascading entity pickers -- matches Kimai's own version for visual consistency. Bundle JS only (Kimai already loads TomSelect CSS globally). Defer to final phase.
|
||||
- **Tabler btn-group**: Mode switcher and display toggle UI -- already loaded by Kimai, zero cost.
|
||||
|
||||
**Explicitly rejected:** cal-heatmap, d3-axis, d3-transition, d3-shape, luxon/date-fns, TomSelect CSS bundle, Kimai's KimaiFormSelect.js.
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes):**
|
||||
- Calendar grid layout (weeks x days) with color intensity mapping
|
||||
- Tooltip on hover showing date, hours, entry count
|
||||
- Day-of-week and month labels
|
||||
- Empty state rendering (no-data days visible, not invisible)
|
||||
- Click-through to Kimai timesheet filtered by date
|
||||
- Kimai theme integration via CSS variables
|
||||
- Default trailing 12-month range
|
||||
- Mode switcher UI (segmented control) with year + week modes
|
||||
- Hours/count display toggle (data already present, purely frontend)
|
||||
- Activity filtering (backend `?activity=N` param)
|
||||
- Cascading entity pickers (customer -> project -> activity)
|
||||
- Persistent filter/mode state across re-renders
|
||||
|
||||
**Should have (differentiators):**
|
||||
- Toggle between hours/day and entry count (different questions answered)
|
||||
- Project/activity filter dropdown
|
||||
- Configurable time range (3/6/12 months)
|
||||
- Streak indicator and summary stats row
|
||||
- Day-of-week mode (week view) -- which weekdays are busiest
|
||||
- Time-of-day mode (day view) -- when during the day work happens
|
||||
- Combined day/hour matrix -- full punchcard (7x24 grid)
|
||||
- Color scale legend
|
||||
- Customer-level filtering
|
||||
|
||||
**Defer indefinitely:**
|
||||
- Hour-of-day matrix, export/share, goal setting, animations, multi-user, mobile layout, custom color picker -- all anti-features for a personal tracking widget.
|
||||
**Defer (v2+):**
|
||||
- Configurable date range selector
|
||||
- Animated transitions between modes
|
||||
- Drag-to-select, drill-down charts, export/share
|
||||
- Multi-user comparison, real-time refresh, custom color themes
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture follows a clean separation: thin Twig template (HTML shell + controls), XHR data fetching from a dedicated API controller, and client-side d3 rendering. The widget registers via Kimai's `WidgetInterface` + `kimai.widget` DI tag. Data flows from Kimai's timesheet table through a `HeatmapService` (Doctrine QueryBuilder, GROUP BY date, user-scoped), to a `HeatmapController` (JSON API), to the d3 module (SVG rendering).
|
||||
Refactor the monolithic `renderHeatmap()` into a strategy-pattern mode system. A `ModeController` dispatches to mode-specific renderers (`year.ts`, `week.ts`, `day.ts`, `combined.ts`) that share extracted utilities (tooltip, color scale, cell click handler). A centralized `HeatmapState` object tracks mode, display metric, filters, and cached data. All UI changes (mode switch, filter change, metric toggle) update state then call a unified render dispatcher. The display toggle (hours/count) re-renders from cached data without a new fetch; all other changes trigger a fetch.
|
||||
|
||||
**Major components:**
|
||||
1. **KimaiHeatmapBundle** -- Symfony bundle class implementing `PluginInterface`, auto-discovered by Kimai
|
||||
2. **HeatmapWidget** -- `WidgetInterface` implementation, registers on dashboard, renders Twig template
|
||||
3. **HeatmapController** -- API endpoint (`/api/plugins/heatmap/data`) returning aggregated JSON
|
||||
4. **HeatmapService** -- Server-side aggregation (hours/count per day, timezone-aware, user-scoped)
|
||||
5. **d3 heatmap module** -- TypeScript, calendar grid rendering, event handling, theme integration
|
||||
1. **ModeController** -- mode switching, data fetching orchestration, state management
|
||||
2. **Renderers (year/week/day/combined)** -- mode-specific layout logic implementing a shared `ModeRenderer` interface
|
||||
3. **Shared utilities (tooltip, colorScale)** -- extracted from current `renderHeatmap()`, reused by all modes
|
||||
4. **Filter bar (filters.ts)** -- TomSelect entity pickers with cascade logic, independent of Kimai's form system
|
||||
5. **HeatmapService (PHP)** -- three new aggregation methods (`getWeekdayAggregation`, `getHourlyAggregation`, `getCombinedAggregation`)
|
||||
6. **HeatmapController (PHP)** -- `mode` and `activity` query params, custom cascade endpoints
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **Kimai plugin API instability** -- Pin to a specific Kimai version. Write an integration test that boots the Symfony kernel with the plugin loaded. Subscribe to Kimai releases for breaking change awareness.
|
||||
2. **Timezone mismatch (PHP vs JS day boundaries)** -- Aggregate by day on the PHP side using `$user->getTimezone()`. Send date strings to the frontend, never raw timestamps. Compare output against Kimai's own reports.
|
||||
3. **Widget system misunderstanding** -- Use `WidgetInterface` + `kimai.widget` DI tag from the start. Do not build a standalone controller page. Study existing Kimai widgets before coding.
|
||||
4. **Asset loading failures** -- Ship prebuilt JS in `Resources/public/`, loaded via `<script>` in Twig. Do not hook into Kimai's Webpack Encore. Start with inline script as fallback.
|
||||
5. **Nix + PHP dev environment** -- Timebox to 1 day. Use SQLite. Set `COMPOSER_HOME` to a writable temp dir. Fall back to Docker if stuck.
|
||||
1. **KimaiFormSelect cannot be used standalone** -- It depends on Kimai's plugin container (`getContainer().getPlugin('api')`). Selects with `data-api-url` inside the widget card silently fail. Prevention: roll your own cascade with plain `fetch()` calls to custom controller endpoints.
|
||||
|
||||
2. **Tooltip DOM leaks on mode switch** -- Tooltips are appended to `document.body`, outside the SVG container. `container.innerHTML = ''` does not clean them up. Prevention: shared tooltip module with a single reusable tooltip div.
|
||||
|
||||
3. **Mode state lost on filter change** -- Current `doRender()` always calls `renderHeatmap()` (year view). Without a state object, any filter change resets the mode. Prevention: implement `HeatmapState` BEFORE adding any new mode.
|
||||
|
||||
4. **Color scale domain mismatch across modes** -- Year mode maxes at ~12h, week mode at ~200h, day mode at ~3h. A shared scale makes some modes unreadable. Prevention: each mode computes its own color scale domain.
|
||||
|
||||
5. **TomSelect bundle duplication** -- Kimai already bundles TomSelect but does not expose it globally. Importing it again adds ~30KB. Prevention: start with plain `<select>` elements; add TomSelect only if list sizes demand it, and verify `window.TomSelect` availability first.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
### Phase 0: Development Environment
|
||||
**Rationale:** Everything blocks on a working Kimai instance with the plugin loaded. Nix+PHP setup is the highest-risk non-code task.
|
||||
**Delivers:** `nix develop` shell with PHP 8.2, Composer, Node 22, local Kimai on SQLite, plugin symlinked into `var/plugins/`.
|
||||
**Avoids:** Pitfall 5 (Nix+PHP complexity) -- timeboxed to 1 day with Docker fallback.
|
||||
### Phase 1: Renderer Refactor + State Management
|
||||
|
||||
### Phase 1: Plugin Scaffold + Data Layer
|
||||
**Rationale:** Proves the plugin loads, widget appears on dashboard, and data flows correctly. These are the hardest unknowns (Kimai plugin API, widget system, timezone aggregation).
|
||||
**Delivers:** Empty widget visible on dashboard. API endpoint returning correct per-day aggregated JSON. PHPUnit tests for service and controller.
|
||||
**Addresses:** Table stakes groundwork (no visible features yet, but the pipeline works end-to-end).
|
||||
**Avoids:** Pitfalls 1, 2, 3, 7 (plugin API, timezone, widget system, asset loading).
|
||||
**Rationale:** Everything depends on this. Cannot add modes without the renderer interface. Cannot coordinate filters/modes without state management. Zero new features -- pure refactor that preserves existing behavior.
|
||||
**Delivers:** Strategy-pattern renderer system, extracted shared utilities (tooltip, colorScale), `HeatmapState` object, `ModeRenderer` interface, year-view refactored into `renderers/year.ts`.
|
||||
**Addresses:** Architectural foundation for all subsequent phases.
|
||||
**Avoids:** Pitfall 4 (mode state lost), Pitfall 2 (tooltip leaks), Pitfall 12 (renderer divergence).
|
||||
|
||||
### Phase 2: Core Heatmap Visualization
|
||||
**Rationale:** With data flowing, build the actual product. The calendar grid with all table-stakes features is the MVP.
|
||||
**Delivers:** d3 calendar heatmap rendering from API data. Tooltips, labels, empty state, click-through, theme integration.
|
||||
**Addresses:** All table-stakes features from FEATURES.md.
|
||||
**Avoids:** Pitfalls 4, 8, 9 (d3 bundle size, color theming, empty state).
|
||||
### Phase 2: Mode Switcher + Week Mode + Display Toggle
|
||||
|
||||
### Phase 3: Interactivity and Polish
|
||||
**Rationale:** Differentiators that make the widget genuinely useful beyond a static picture. Low complexity, high value.
|
||||
**Delivers:** Hours/count toggle, project filter dropdown, configurable time range, streak indicator, summary stats.
|
||||
**Addresses:** All differentiator features from FEATURES.md.
|
||||
**Avoids:** Pitfall 10 (hardcoded URLs -- generate timesheet URL template server-side).
|
||||
**Rationale:** First user-visible v1.1 feature with zero backend changes. Week mode aggregates existing daily data client-side. Proves the mode system works end-to-end before adding backend complexity.
|
||||
**Delivers:** Mode switcher UI (Tabler segmented control), week-mode renderer, hours/count toggle, all wired through state management.
|
||||
**Addresses:** Mode switcher UI, day-of-week mode, hours/count toggle (3 table-stakes features).
|
||||
**Avoids:** Pitfall 6 (color scale mismatch -- week mode has very different domain than year), Pitfall 10 (toggle is a full re-render, not CSS swap), Pitfall 11 (week-start must propagate).
|
||||
|
||||
### Phase 3: Backend Aggregation + Activity Filtering + Custom Endpoints
|
||||
|
||||
**Rationale:** Day and combined modes need new backend queries. Activity filtering needs a new query param. Custom cascade endpoints are needed for Phase 5's entity pickers and avoid API auth pitfalls. Group all backend work together.
|
||||
**Delivers:** `getHourlyAggregation()`, `getCombinedAggregation()`, `getWeekdayAggregation()` (backend optimization over client-side), `mode` query param on data endpoint, `activity` filter param, cascade endpoints (`/heatmap/customers`, `/heatmap/projects`, `/heatmap/activities`).
|
||||
**Addresses:** Backend foundation for day/combined modes, activity filtering.
|
||||
**Avoids:** Pitfall 3 (hour query performance -- profile with EXPLAIN), Pitfall 5 (API response format -- own endpoints return simple `{id, name}`), Pitfall 8 (API auth -- own endpoints use session auth).
|
||||
|
||||
### Phase 4: Day + Combined Visualization Modes
|
||||
|
||||
**Rationale:** Backend data is ready from Phase 3. Fill in the remaining renderers.
|
||||
**Delivers:** Day-mode (24-column hour-of-day heatmap), combined mode (7x24 punchcard matrix), color scale legend.
|
||||
**Addresses:** Time-of-day mode, combined matrix, legend (differentiator features).
|
||||
**Avoids:** Pitfall 6 (per-mode color scales), Pitfall 11 (week-start in combined matrix).
|
||||
|
||||
### Phase 5: Entity Pickers (TomSelect Cascade)
|
||||
|
||||
**Rationale:** Most complex integration point. Everything else works with the existing plain project dropdown. Upgrades filtering UX without blocking other features. Needs runtime verification of TomSelect global availability.
|
||||
**Delivers:** TomSelect-enhanced customer/project/activity pickers with cascading, replaces plain `<select>`, uses custom endpoints from Phase 3.
|
||||
**Addresses:** Cascading entity pickers, customer-level filtering.
|
||||
**Avoids:** Pitfall 1 (KimaiFormSelect dependency -- own cascade logic), Pitfall 7 (TomSelect duplication -- verify global first, bundle only as fallback).
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- Phase 0 before everything: no plugin code without a working dev environment.
|
||||
- Phase 1 combines scaffold + data because the scaffold alone is not testable in a meaningful way -- you need data flowing to confirm the widget system integration works.
|
||||
- Phase 2 is pure frontend work that depends on Phase 1's API but is otherwise independent.
|
||||
- Phase 3 layers interactivity onto an already-working heatmap. Each feature is independently shippable.
|
||||
- This ordering front-loads risk: the hardest unknowns (Kimai plugin API, widget system, timezone handling) are resolved in Phase 1. Phases 2 and 3 use well-established d3 patterns with high confidence.
|
||||
- Phases 1-2 deliver visible value with zero backend changes, validating the architecture cheaply.
|
||||
- Phase 3 groups all backend work (queries + endpoints) to minimize PHP context-switching.
|
||||
- Phase 4 depends on Phase 3's data but is purely frontend work.
|
||||
- Phase 5 is isolated from everything else and has the most unknowns (TomSelect availability, cascade edge cases). Doing it last means it cannot block other features.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase 0:** Kimai Nix setup is uncommon. May need to inspect current Kimai `composer.json` and PHP extension requirements live.
|
||||
- **Phase 1:** Kimai's `WidgetInterface`, DI tags, and route registration need verification against the target Kimai version. The plugin API documentation is sparse -- reading existing plugin source code is essential.
|
||||
- **Phase 5 (Entity Pickers):** TomSelect global availability must be verified in the dev environment before implementation. Cascade edge cases (global activities, empty lists) need testing against real data.
|
||||
|
||||
Phases with standard patterns (skip research):
|
||||
- **Phase 2:** d3 calendar heatmap is a well-documented pattern with official Observable examples. TypeScript + esbuild bundling is straightforward.
|
||||
- **Phase 3:** Filter dropdowns, toggle buttons, and URL template interpolation are standard frontend work.
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1 (Refactor):** Standard strategy pattern extraction. Existing code is well-understood.
|
||||
- **Phase 2 (Modes + Toggle):** Tabler segmented controls are documented. Week-mode is simple client-side aggregation.
|
||||
- **Phase 3 (Backend):** Doctrine DQL GROUP BY queries -- same pattern as existing `getDailyAggregation()`.
|
||||
- **Phase 4 (Renderers):** d3 rect grid rendering -- same pattern as year-view with different layout math.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | MEDIUM | PHP/Symfony/d3 choices are solid. Exact Kimai version requirements need live verification. |
|
||||
| Features | MEDIUM-HIGH | GitHub-style heatmap is a proven pattern. Feature priorities are well-reasoned from reference products. |
|
||||
| Architecture | MEDIUM | Symfony bundle + API + d3 rendering is sound. Kimai-specific widget API details (interface methods, DI tags, Twig blocks) need verification. |
|
||||
| Pitfalls | MEDIUM-HIGH | Timezone and d3 pitfalls are universal and well-understood. Kimai-specific pitfalls (plugin API instability, asset pipeline) based on training data, not live docs. |
|
||||
| Stack | HIGH | No new dependencies except TomSelect. All tools verified against existing codebase and Kimai source. |
|
||||
| Features | HIGH | Feature set derived from existing codebase analysis and time-tracking domain conventions. Clear table-stakes vs differentiator separation. |
|
||||
| Architecture | HIGH | Based on direct reading of existing plugin code and Kimai internals. Strategy pattern is well-understood. |
|
||||
| Pitfalls | HIGH | All pitfalls identified from actual source code analysis (line numbers cited). Prevention strategies are concrete. |
|
||||
|
||||
**Overall confidence:** MEDIUM
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **Kimai widget API verification**: The exact `WidgetInterface` methods, `AbstractWidgetType` hierarchy, and `kimai.widget` DI tag must be verified against the target Kimai release. This is the single biggest unknown.
|
||||
- **Kimai asset serving for plugins**: Whether Kimai uses `assets:install` to copy `Resources/public/` to `public/bundles/` or has a different mechanism needs checking.
|
||||
- **Kimai CSS variable names**: The actual theme variable names for colors are unknown. Fallback values mitigate this, but proper theme integration requires inspecting a running Kimai instance.
|
||||
- **Kimai timesheet URL structure**: The route name and filter parameter format for click-through navigation must be verified.
|
||||
- **Twig widget template blocks**: The exact block names (`widget_content`, `widget_javascript`) and base template path need verification.
|
||||
- **TomSelect global availability:** Must verify `window.TomSelect` in browser console on Kimai dashboard before Phase 5. If not available, decision: bundle (~30KB cost) or stick with plain selects.
|
||||
- **Hour-level query performance:** Profile `GROUP BY HOUR(t.begin)` with EXPLAIN against the dev database during Phase 3. If slow, consider narrowing date range for day/combined modes or client-side aggregation of raw entries.
|
||||
- **`t.begin` time component:** Verify that Kimai's Timesheet entity stores actual time-of-day in `t.begin` (not just date). Required for day and combined modes.
|
||||
- **Week-start in DAYOFWEEK():** MySQL's `DAYOFWEEK()` returns 1=Sunday. Must map correctly based on user's week-start preference. Test both configurations.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- d3.js documentation and calendar heatmap patterns (d3js.org, Observable)
|
||||
- Symfony bundle system documentation (symfony.com)
|
||||
- Vitest and esbuild documentation
|
||||
- General timezone handling and SVG performance patterns
|
||||
- Kimai source code (`dev/kimai/`) -- KimaiFormSelect.js, webpack.config.js, API controllers, form extensions
|
||||
- Existing plugin codebase -- `assets/src/heatmap.ts`, `src/Service/HeatmapService.php`, `src/Controller/HeatmapController.php`
|
||||
- d3.js documentation: https://d3js.org/
|
||||
- TomSelect documentation: https://tom-select.js.org/
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- Kimai plugin development documentation (kimai.org/documentation/plugin-development.html) -- from training data
|
||||
- Kimai GitHub repository structure and existing plugins -- from training data
|
||||
- Kimai widget system internals -- from training data
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Kimai Twig template block names and asset path conventions -- inferred, needs validation
|
||||
- Kimai CSS custom property names -- inferred, needs validation
|
||||
- Tabler segmented control docs: https://docs.tabler.io/ui/components/segmented-control
|
||||
- d3 heatmap patterns: https://d3-graph-gallery.com/heatmap.html
|
||||
- tom-select on npm/bundlephobia (bundle size analysis)
|
||||
|
||||
---
|
||||
*Research completed: 2026-04-08*
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue