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

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 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:

// 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:

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:

  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