376 lines
17 KiB
Markdown
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 |
|