kimai-plugin-heatmap/.planning/research/ARCHITECTURE.md

376 lines
17 KiB
Markdown

# Architecture: v1.1 Integration
**Domain:** Kimai heatmap plugin -- visualization modes, TomSelect pickers, display toggle
**Researched:** 2026-04-08
**Confidence:** HIGH (based on reading existing codebase + Kimai source in dev/)
## Current Architecture Summary
```
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
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)
```
**Renderer interface:**
```typescript
interface ModeRenderer {
render(
container: HTMLElement,
data: ModeData,
config: HeatmapConfig,
onCellClick?: (context: CellClickContext) => void,
weekStart?: string,
): void;
}
```
Each renderer is a pure function (no shared mutable state). The mode controller manages which one is active and handles data fetching.
**Backend data requirements per mode:**
| 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 |
**API endpoint evolution:**
```
Current: GET /heatmap/data?project=N
-> {days: [{date, hours, count}], range: {begin, end}}
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
}
```
**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
```
**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.
**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.
**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.
### 3. Display Toggle (Hours vs Count)
**This is the simplest change.** The data already contains both `hours` and `count` per day entry. The toggle is purely a frontend concern:
- 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
**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.
### 4. Filter Bar Layout
**Current layout:** Heatmap SVG area + project dropdown in a flex row, stats below.
**New layout:**
```
+------------------------------------------------------------------+
| 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 |
+------------------------------------------------------------------+
```
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;
}
```
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
Dependencies dictate this sequence:
### Phase 1: Refactor Existing Code (Foundation)
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.
**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).
**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
```
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
Option 1 is the safe default. Option 2 is fragile.
## Sources
- 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 Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| 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 |