17 KiB
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:
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
KimaiContainerinstance providing API, date utils, and translation plugins - Form elements rendered by Symfony with specific
data-*attributes - Global
documentchange 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:
- Importing TomSelect + reimplementing cascade logic (the sensible path)
- 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:
// 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.hoursorentry.countfor 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:
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:
// 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:
- Add
tom-selectto our npm dependencies and bundle it (adds ~30KB) - Use
esbuild --external:tom-selectand 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 |