# 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 ` 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 `