diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index b9c7013..d06ecfd 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,467 +1,376 @@ -# Architecture Patterns +# Architecture: v1.1 Integration -**Domain:** Kimai dashboard widget plugin (Symfony bundle + d3.js) +**Domain:** Kimai heatmap plugin -- visualization modes, TomSelect pickers, display toggle **Researched:** 2026-04-08 -**Confidence:** MEDIUM (based on training knowledge of Kimai 2.x plugin system; no live doc verification possible) +**Confidence:** HIGH (based on reading existing codebase + Kimai source in dev/) -## Recommended Architecture - -### High-Level Overview +## Current Architecture Summary ``` -+------------------------------------------+ -| Kimai Dashboard (Twig) | -| +------------------------------------+ | -| | HeatmapWidget (registered via | | -| | WidgetInterface + DI tag) | | -| | +------------------------------+ | | -| | | Twig template renders | | | -| | |
| | | -| | | + inline d3.js bundle | | | -| | +------------------------------+ | | -| +------------------------------------+ | -+------------------------------------------+ - | ^ - | XHR/fetch | JSON - v | -+------------------------------------------+ -| HeatmapController (Symfony) | -| GET /api/heatmap?range=&project= | -| - Queries TimesheetRepository | -| - Aggregates hours/counts per day | -| - Returns JSON | -+------------------------------------------+ - | - v -+------------------------------------------+ -| Kimai Database (timesheet table) | -| - begin, end, duration, project_id, | -| activity_id, user_id | -+------------------------------------------+ +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) | -Kimai plugins are standard Symfony bundles placed in `var/plugins/` (for local install) or loaded via Composer. The canonical directory layout: +## 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). ``` -KimaiHeatmapBundle/ - KimaiHeatmapBundle.php # Bundle class (extends PluginInterface) - DependencyInjection/ - KimaiHeatmapExtension.php # Loads services.yaml - Resources/ - config/ - services.yaml # Service definitions + DI tags - routes.yaml # Plugin routes (controller endpoints) - views/ - widget/ - heatmap.html.twig # Widget Twig template - public/ - heatmap.js # Compiled d3 heatmap script - heatmap.css # Widget styles - Widget/ - HeatmapWidget.php # WidgetInterface implementation - Controller/ - HeatmapController.php # API controller - Service/ - HeatmapService.php # Data aggregation - EventSubscriber/ # Optional: hook into Kimai events - composer.json # Package metadata, autoload config +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) ``` -### Bundle Class +**Renderer interface:** -```php -// KimaiHeatmapBundle.php -namespace KimaiPlugin\KimaiHeatmapBundle; - -use App\Plugin\PluginInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; - -class KimaiHeatmapBundle extends Bundle implements PluginInterface -{ - public function build(ContainerBuilder $container): void - { - parent::build($container); - } +```typescript +interface ModeRenderer { + render( + container: HTMLElement, + data: ModeData, + config: HeatmapConfig, + onCellClick?: (context: CellClickContext) => void, + weekStart?: string, + ): void; } ``` -**Key point:** Kimai plugins implement `App\Plugin\PluginInterface` which extends Symfony's `BundleInterface`. The namespace MUST be `KimaiPlugin\` for Kimai's plugin loader to discover it. +Each renderer is a pure function (no shared mutable state). The mode controller manages which one is active and handles data fetching. -### DependencyInjection Extension +**Backend data requirements per mode:** -```php -// DependencyInjection/KimaiHeatmapExtension.php -namespace KimaiPlugin\KimaiHeatmapBundle\DependencyInjection; +| 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 | -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +**API endpoint evolution:** -class KimaiHeatmapExtension extends Extension implements PrependExtensionInterface -{ - public function load(array $configs, ContainerBuilder $container): void - { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.yaml'); - } +``` +Current: GET /heatmap/data?project=N + -> {days: [{date, hours, count}], range: {begin, end}} - public function prepend(ContainerBuilder $container): void - { - // Prepend route config so Kimai loads our routes - $container->prependExtensionConfig('kimai', [ - 'plugin' => [ - 'heatmap' => [ - 'routes' => '@KimaiHeatmapBundle/Resources/config/routes.yaml', - ], - ], - ]); - } +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 } ``` -## Dashboard Widget System +**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 +``` -Kimai's dashboard renders widgets that implement `App\Widget\WidgetInterface`. Widgets are registered via Symfony DI tags. +**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. -### Widget Interface +**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. -```php -// Widget/HeatmapWidget.php -namespace KimaiPlugin\KimaiHeatmapBundle\Widget; +**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. -use App\Widget\WidgetInterface; -use App\Widget\Type\AbstractWidgetType; -use App\Repository\TimesheetRepository; +### 3. Display Toggle (Hours vs Count) -class HeatmapWidget extends AbstractWidgetType -{ - public function __construct( - private TimesheetRepository $timesheetRepository - ) {} +**This is the simplest change.** The data already contains both `hours` and `count` per day entry. The toggle is purely a frontend concern: - public function getWidth(): int - { - return WidgetInterface::WIDTH_FULL; // Full-width dashboard widget - } +- 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 - public function getHeight(): int - { - return WidgetInterface::HEIGHT_LARGE; - } +**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. - public function getTitle(): string - { - return 'Activity Heatmap'; - } +### 4. Filter Bar Layout - public function getTemplateName(): string - { - return '@KimaiHeatmap/widget/heatmap.html.twig'; - } +**Current layout:** Heatmap SVG area + project dropdown in a flex row, stats below. - public function getId(): string - { - return 'HeatmapWidget'; - } +**New layout:** - public function getData(array $options = []): mixed - { - // Initial data can be passed to Twig, or widget - // can fetch data via XHR from the API endpoint - return []; - } +``` ++------------------------------------------------------------------+ +| 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 | ++------------------------------------------------------------------+ +``` - public function getPermissions(): array - { - return ['view_own_timesheet']; - } +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; } ``` -### Service Registration (services.yaml) - -```yaml -services: - KimaiPlugin\KimaiHeatmapBundle\Widget\HeatmapWidget: - arguments: - $timesheetRepository: '@App\Repository\TimesheetRepository' - tags: - - { name: 'kimai.widget', priority: 50 } - - KimaiPlugin\KimaiHeatmapBundle\Controller\HeatmapController: - tags: ['controller.service_arguments'] - - KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService: - arguments: - $timesheetRepository: '@App\Repository\TimesheetRepository' -``` - -The `kimai.widget` tag is how the widget gets discovered and rendered on the dashboard. The `priority` controls ordering (higher = appears earlier). - -## API Endpoint for Heatmap Data - -### Controller - -```php -// Controller/HeatmapController.php -namespace KimaiPlugin\KimaiHeatmapBundle\Controller; - -use App\Controller\AbstractController; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Security\Http\Attribute\IsGranted; - -#[Route(path: '/api/heatmap')] -#[IsGranted('view_own_timesheet')] -class HeatmapController extends AbstractController -{ - #[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])] - public function getData(Request $request, HeatmapService $service): JsonResponse - { - $begin = new \DateTime($request->query->get('begin', '-1 year')); - $end = new \DateTime($request->query->get('end', 'now')); - $projectId = $request->query->getInt('project', 0); - - $data = $service->getAggregatedData( - user: $this->getUser(), - begin: $begin, - end: $end, - projectId: $projectId ?: null - ); - - return new JsonResponse($data); - } -} -``` - -### Route Registration (routes.yaml) - -```yaml -heatmap_api: - resource: '../../Controller/' - type: annotation # or attribute for PHP 8+ - prefix: /api/plugins/heatmap -``` - -### JSON Response Shape - -```json -{ - "days": [ - { "date": "2026-01-15", "hours": 7.5, "count": 3 }, - { "date": "2026-01-16", "hours": 4.25, "count": 2 } - ], - "range": { "begin": "2025-04-08", "end": "2026-04-08" }, - "maxHours": 12.0, - "projects": [ - { "id": 1, "name": "Aitia" }, - { "id": 2, "name": "Aleph Garden" } - ] -} -``` - -## d3.js Integration in Symfony/Twig - -### Strategy: Standalone JS file loaded in Twig template - -Kimai uses Webpack Encore for asset management, but plugins typically ship prebuilt JS/CSS in `Resources/public/`. Kimai copies these to `public/bundles//` via `assets:install`. - -**Recommendation:** Ship a self-contained d3 heatmap script rather than integrating with Kimai's Webpack build. This avoids coupling to Kimai's internal build pipeline. - -### Twig Template - -```twig -{# Resources/views/widget/heatmap.html.twig #} -{% block widget_content %} -
-
- - -
-
-
-{% endblock %} - -{% block widget_javascript %} - -{% endblock %} -``` - -### d3 Module Design - -``` -assets/ - src/ - heatmap.ts # Entry point, initializes widget - calendar-heatmap.ts # d3 calendar heatmap rendering - data-fetcher.ts # Fetch API wrapper for controller endpoint - color-scale.ts # CSS variable-aware color scaling - types.ts # TypeScript interfaces - package.json # d3 + build deps - tsconfig.json - rollup.config.js # Bundle to single file for Resources/public/ -``` - -The JS gets bundled (via rollup or esbuild) into a single `heatmap.js` that ships in `Resources/public/`. d3 is bundled in (tree-shaken to only calendar/scale modules). - -### CSS Variable Integration - -```css -/* Use Kimai's theme CSS custom properties */ -.heatmap-cell { - fill: var(--chart-color-empty, #ebedf0); -} -.heatmap-cell[data-level="1"] { fill: var(--chart-color-low, #9be9a8); } -.heatmap-cell[data-level="2"] { fill: var(--chart-color-medium, #40c463); } -.heatmap-cell[data-level="3"] { fill: var(--chart-color-high, #30a14e); } -.heatmap-cell[data-level="4"] { fill: var(--chart-color-max, #216e39); } -``` - -**Note:** Kimai uses AdminLTE / Tabler themes. The actual CSS variable names need to be verified against the running Kimai instance. Fallback values ensure the heatmap works even if variables are missing. - -## Data Flow - -``` -1. User loads Kimai dashboard -2. Symfony renders dashboard, includes HeatmapWidget -3. HeatmapWidget renders Twig template (empty chart container + controls) -4. heatmap.js initializes on DOMContentLoaded -5. JS reads data-url attribute, fetches /api/plugins/heatmap/data?range=365 -6. HeatmapController receives request, delegates to HeatmapService -7. HeatmapService queries TimesheetRepository with DQL/QueryBuilder - - GROUP BY DATE(begin), SUM(duration), COUNT(*) - - Filtered by user, date range, optional project -8. Controller returns JSON response -9. d3.js receives JSON, builds calendar heatmap SVG -10. User interactions (mode toggle, project filter, day click) handled client-side - - Mode toggle: re-renders with different data key (hours vs count) - - Project filter: new fetch with ?project=ID parameter - - Day click: window.location to /en/timesheet/?daterange=YYYY-MM-DD -``` - -## Patterns to Follow - -### Pattern 1: Widget Data via Dedicated API - -**What:** Widget template is a thin shell; data fetching happens via XHR to a dedicated controller endpoint. -**Why:** Separates rendering from data. Widget loads fast (empty shell), data arrives async. Enables filtering/re-fetching without page reload. -**When:** Always for data-heavy widgets. - -### Pattern 2: Permission-Gated Everything - -**What:** Both the widget visibility and the API endpoint check `view_own_timesheet` permission. -**Why:** Kimai has a role-based permission system. Widget should only appear for users with timesheet access, and the API must independently verify permissions (defense in depth). - -### Pattern 3: User-Scoped Queries - -**What:** Always filter by `$this->getUser()` in queries. -**Why:** Personal time tracking -- users should only see their own data. Even if Kimai handles this at the repository level, be explicit. - -## Anti-Patterns to Avoid - -### Anti-Pattern 1: Embedding Data in Twig - -**What:** Passing all heatmap data through `getData()` into Twig as PHP arrays. -**Why bad:** Large datasets (365 days of data) bloat the HTML. Cannot update without page reload. Makes filtering require full page requests. -**Instead:** Thin Twig template + XHR data fetching. - -### Anti-Pattern 2: Hooking into Kimai's Webpack Build - -**What:** Requiring the plugin user to rebuild Kimai's assets. -**Why bad:** Kimai's internal asset pipeline is not a public API. Updates can break your build. Extra install step for users. -**Instead:** Ship prebuilt JS/CSS in `Resources/public/`. - -### Anti-Pattern 3: Raw SQL Queries - -**What:** Writing raw SQL instead of using Doctrine QueryBuilder or DQL. -**Why bad:** Breaks database portability (Kimai supports MySQL and SQLite). Harder to test. -**Instead:** Use Doctrine QueryBuilder with Kimai's existing repository patterns. +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 -The components have clear dependencies that dictate implementation order: +Dependencies dictate this sequence: -``` -Phase 1: Plugin Scaffold - - Bundle class, DI extension, services.yaml, routes.yaml - - Empty widget registered on dashboard (renders "Hello Heatmap") - - Nix devshell with local Kimai instance - Validates: Plugin loads, widget appears, dev env works +### Phase 1: Refactor Existing Code (Foundation) -Phase 2: Data Layer - - HeatmapService with aggregation queries - - HeatmapController returning JSON - - PHPUnit tests for service + controller - Validates: API returns correct aggregated data - Depends on: Phase 1 (routing, DI) +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. -Phase 3: Heatmap Visualization - - d3.js calendar heatmap module (TypeScript) - - Build pipeline (rollup/esbuild -> single bundle) - - Twig template integration - - JS tests for rendering logic - Validates: Heatmap renders from API data - Depends on: Phase 2 (API endpoint exists) +**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). -Phase 4: Interactivity - - Mode toggle (hours vs count) - - Project/activity filter dropdowns - - Day-click navigation to timesheet - - Kimai theme CSS variable integration - Validates: Full feature set works - Depends on: Phase 3 (heatmap renders) +**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 `` feels foreign. Once activity filtering is promised, the cascade is expected. | Medium-High | See Complexity Notes below for full analysis of integration approach. | +| Activity filtering | Descoped from v1.0. Now the primary reason for the entity picker upgrade. Users who filter by project will expect activity filtering too. | Medium | Backend: add `?activity=N` param to `HeatmapController::data()`, add `AND t.activity = :activity` clause to `HeatmapService::getDailyAggregation()`. Frontend: wire activity picker into fetch URL. Kimai API route `get_activities` already accepts `?project=N` for cascading. | +| Persistent filter/mode state | Switching modes or filters then refreshing should not reset everything. | Low | Use URL query params (shareable, bookmarkable) or `localStorage`. URL params preferred. | ## Differentiators -Features that elevate the widget beyond a static pretty picture. Not expected by default, but each adds meaningful value for a personal time-tracking use case. +Features that elevate this from "yet another heatmap" to a genuinely useful time-analysis tool. Not expected, but valued. | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| -| Toggle between hours and entry count | Different questions: "how much did I work?" vs "how consistently did I track?" -- GitHub only shows one metric | Low | Button or toggle in widget header; already in PROJECT.md requirements | -| Project/activity filter | Focus on one project at a time to see its cadence | Medium | Dropdown populated from Kimai data; re-renders heatmap on change | -| Configurable time range | See last 3 months, 6 months, full year, or custom range | Medium | Range selector in widget header; affects data query and grid size | -| Streak indicator | "Current streak: 12 days" -- gamification that motivates consistency | Low | Simple calculation: count consecutive non-zero days ending at today | -| Summary stats row | Total hours, average hours/day, busiest day -- quick numbers alongside the visual | Low | Small text row below or above the heatmap | -| Weekend vs weekday distinction | Subtle visual indicator (border, opacity) for weekends | Low | Helps distinguish work patterns from weekend work | -| Responsive widget sizing | Widget that adapts if Kimai dashboard column width changes | Medium | SVG viewBox scaling; cell size calculation based on container width | +| Time-of-day heatmap (day mode) | Shows *when during the day* work happens. Reveals morning-person vs night-owl patterns. Uncommon in time-tracking tools. | High | Requires new backend query: `GROUP BY HOUR(t.begin)` for hour-level aggregation. New data shape: `HourEntry { hour: number, hours: number, count: number }`. New d3 layout: 24-row/column grid. Needs new API response format or a `mode` query param. | +| Combined day/hour matrix | Full punchcard: 7 rows (days) x 24 cols (hours). The GitHub punchcard view. Shows exactly when work happens across the week. | High | Backend: `GROUP BY DAYOFWEEK(t.begin), HOUR(t.begin)`. New data shape: `PunchcardEntry { day: number, hour: number, hours: number, count: number }`. d3 layout: 7x24 grid with circle sizes or color intensity. Most complex visualization but highest insight value. | +| Customer-level filtering | Filter heatmap by customer (one level above project). Useful for seeing client-specific patterns. | Low | The cascade handles this naturally: customer picker drives project picker via `get_projects?customer=N`. Backend `HeatmapService` needs `?customer=N` param that joins through `t.project.customer`. | +| Color scale legend | Small gradient bar showing what colors mean (0h to Xh). | Low | Standard d3 pattern. Horizontal gradient with tick labels. Applies to all modes. | +| Animated transitions between modes | Smooth morphing when switching views (cells rearrange, fade, resize). | Medium | d3 `transition()` API. Key: use consistent `key` functions in `.join()` so d3 matches elements across renders. Polish, not critical. | ## Anti-Features -Things to deliberately NOT build. Each would add complexity without serving the core "at a glance, see where your time went" purpose. +Things to explicitly NOT build in v1.1. | Anti-Feature | Why Avoid | What to Do Instead | |--------------|-----------|-------------------| -| Real-time updates / live refresh | Overengineered for a dashboard widget; page reload is fine for daily granularity data | Refresh data on page load only | -| Billable vs non-billable split | Out of scope per PROJECT.md; this is personal tracking, not client billing | Single aggregate view | -| Export/share heatmap as image | Out of scope per PROJECT.md; no audience to share with for personal use | Users can screenshot if needed | -| Hour-of-day heatmap (2D matrix) | Different visualization entirely; scope creep that doubles the frontend work | Stick to the calendar grid; could be a separate widget later | -| Drill-down charts within the widget | Clicking a day to show a pie chart of projects within the widget adds major complexity | Click-through to Kimai's existing timesheet/report views instead | -| Multi-user comparison | Personal tracking tool, not a team dashboard | Single-user view only | -| Goal setting / targets | Adds state management, settings UI, persistence -- a separate feature domain | Streak indicator covers the motivational angle simply | -| Animated transitions on data change | Cool but adds JS complexity for near-zero utility on a dashboard widget | Instant re-render on filter change | -| Mobile-specific layout | Out of scope per PROJECT.md; Kimai dashboard is a desktop experience | Standard responsive SVG is sufficient | -| Custom color theme picker | Theme integration with Kimai variables is sufficient; custom palettes add a settings UI nobody needs | Use Kimai theme variables, pick a single well-tested palette | +| Configurable date range selector | Adds significant UI complexity (date pickers, presets). The fixed 1-year window works for personal use. | Defer to v1.2+. If needed later, a simple "6mo / 1yr / 2yr" segmented control is sufficient. | +| Multi-user comparison | This is a personal tracking tool, not a team dashboard. | Out of scope per PROJECT.md. | +| Export/share heatmap as image | No audience for a personal tool. SVG-to-PNG is fragile. | Out of scope per PROJECT.md. | +| Custom color theme picker | Kimai theme integration is sufficient. Adding a color picker adds options without insight. | Keep hardcoded green scale (matches GitHub convention). | +| Real-time / auto-refresh | Refresh-on-load is fine. WebSocket or polling adds complexity for no gain. | Out of scope per PROJECT.md. | +| Full Kimai form plugin integration | Kimai's `KimaiFormPlugin` lifecycle (`activateForm`/`destroyForm`) is designed for Symfony form pages, not standalone widgets. Coupling to it means depending on Kimai's internal JS architecture. | Use TomSelect directly. Replicate only the cascading pattern. Style with Kimai's existing TomSelect CSS (already loaded globally). See Complexity Notes. | +| Drag-to-select date ranges | Complex interaction, ambiguous purpose (filter? zoom? export?). | Click-to-navigate to timesheet (existing) is sufficient. | +| Drill-down charts within widget | Clicking a day to show a pie chart of projects adds major complexity. | Click-through to Kimai's existing timesheet/report views instead. | ## Feature Dependencies ``` -Calendar grid layout - --> Color intensity mapping (needs the grid to color) - --> Tooltip on hover (needs cells to attach to) - --> Click-through to detail (needs cells to click) - --> Empty state (needs grid cells for empty days) - --> Day/Month labels (needs the grid structure) - -Toggle hours/count - --> Requires backend to return BOTH hours and count per day - --> Re-maps color intensity without re-fetching data - -Project/activity filter - --> Requires backend filter parameter in data API - --> Dropdown populated via separate Kimai API call (projects list) - --> Triggers data re-fetch + full heatmap re-render - -Configurable time range - --> Requires backend date range parameter in data API - --> Affects grid dimensions (fewer weeks = smaller grid) - --> Triggers data re-fetch + full heatmap re-render - -Streak indicator - --> Depends on the same daily aggregation data as the heatmap - --> No additional backend work if heatmap data is already loaded - -Summary stats - --> Depends on the same daily aggregation data as the heatmap - --> Pure frontend calculation from existing data +Customer picker -> Project picker -> Activity picker (cascade chain) +Activity picker -> Activity filter param in API (backend support) +Mode switcher UI -> All visualization modes (renders) +Hours/count toggle -> Color scale recalculation (applies to all modes) +Day mode (time-of-day) -> New backend aggregation query (HOUR grouping) +Combined matrix -> New backend aggregation query (DAY + HOUR grouping) +Week mode (day-of-week) -> NO new backend query (aggregate DayEntry[] client-side) ``` ## MVP Recommendation -**Phase 1 -- Core heatmap (all table stakes):** -1. Calendar grid with color intensity (the product itself) -2. Tooltips on hover -3. Day/month labels and empty state -4. Click-through to Kimai timesheet view -5. Kimai theme integration +**Phase structure for v1.1:** -**Phase 2 -- Interactivity (high-value differentiators):** -1. Toggle between hours and entry count -2. Project/activity filter dropdown -3. Configurable time range +Phase 1 -- Mode Switcher + Week Mode + Toggle: +1. **Mode switcher UI** -- Tabler `nav-segmented`, initially Year (existing) + Week modes +2. **Week-mode (day-of-week)** -- Client-side aggregation of existing daily data, no backend changes, proves the multi-mode rendering architecture +3. **Hours/count toggle** -- Small segmented control, applies to all modes, low effort +4. **Rendering refactor** -- Extract shared concerns (SVG container, color scale, tooltip) into base; modes provide layout strategy -**Phase 3 -- Polish (low-effort differentiators):** -1. Streak indicator -2. Summary stats row -3. Weekend distinction +Phase 2 -- Entity Pickers + Activity Filtering: +5. **TomSelect entity pickers** -- Replace plain `` elements styled with Kimai's TomSelect CSS classes +- Implement simplified cascading: on customer change, `fetch('/api/projects?customer=N')`, rebuild project options, trigger activity reload +- Use same option grouping pattern (`parentTitle` for optgroups) as Kimai API responses +- ~100-150 lines of cascading logic, much simpler than Kimai's generic system + +**(b) Symfony form fields in Twig template:** +- Render actual `CustomerType`/`ProjectType`/`ActivityType` form fields in the widget's Twig template +- Let Kimai's existing JS auto-enhance them with TomSelect +- Cleaner integration but requires investigation: do Kimai's form plugins activate inside widget Twig blocks? +- Risk: widget Twig templates may not trigger Kimai's form initialization JS + +### Rendering Architecture Refactor + +Current `renderHeatmap()` is tightly coupled to the year-view grid layout. Adding modes requires a strategy pattern: + +- **Shared infrastructure:** SVG container creation, color scale, tooltip, empty state, resize handler +- **Mode-specific:** `generateCells()` / layout function, axis labels, dimensions +- Extract into: `createRenderer(mode)` that returns the appropriate layout strategy +- Each mode is a self-contained module: `yearMode.ts`, `weekMode.ts`, `dayMode.ts`, `combinedMode.ts` + +### Backend Aggregation Queries + +| Mode | SQL Aggregation | New Backend Code? | +|------|-----------------|-------------------| +| Year (existing) | `GROUP BY DATE(t.date)` | No | +| Week (day-of-week) | Client-side aggregation of daily data | No | +| Day (time-of-day) | `GROUP BY HOUR(t.begin)` | Yes -- new method in `HeatmapService` | +| Combined | `GROUP BY DAYOFWEEK(t.begin), HOUR(t.begin)` | Yes -- new method in `HeatmapService` | + +Note: day and combined modes need `t.begin` (timesheet start datetime), not `t.date`. Verify `t.begin` is available and contains time component in Kimai's Timesheet entity. ## Sources -- GitHub contribution graph: well-known UX pattern (5 color levels, year grid, tooltips, contribution count) -- GitLab activity calendar: same pattern, confirms universality -- WakaTime dashboard: adds analytics layer (coding time heatmap, project breakdowns, streak tracking) -- RescueTime: productivity scoring approach, hour-of-day patterns -- Toggl Track insights: report-centric visualizations, project breakdowns -- cal-heatmap.com: popular open-source JS calendar heatmap library, API design reference -- All sourced from training data (no live verification available) -- MEDIUM confidence +- Tabler segmented control: https://docs.tabler.io/ui/components/segmented-control +- GitHub PunchCard visualization: https://github.com/vnau/punchcard +- Segmented control UX best practices: https://mobbin.com/glossary/segmented-control +- Apple HIG segmented controls: https://developer.apple.com/design/human-interface-guidelines/segmented-controls +- d3 heatmap patterns: https://d3-graph-gallery.com/heatmap.html +- Kimai form select cascading: local `dev/kimai/assets/js/forms/KimaiFormSelect.js` (lines 386-447) +- Kimai API routes: `get_projects` at `src/API/ProjectController.php:56`, `get_activities` at `src/API/ActivityController.php:54` +- Kimai SelectWithApiDataExtension: local `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php` +- TomSelect: https://tom-select.js.org/ diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md index 5493543..a9a9db8 100644 --- a/.planning/research/PITFALLS.md +++ b/.planning/research/PITFALLS.md @@ -1,178 +1,162 @@ # Domain Pitfalls -**Domain:** Kimai dashboard widget plugin (Symfony bundle + d3.js heatmap) +**Domain:** Kimai heatmap plugin v1.1 -- visualization modes, TomSelect entity pickers, cascading filters, display toggle **Researched:** 2026-04-08 -**Note:** Web search/fetch tools were unavailable. Findings are based on training data knowledge of Kimai, Symfony bundles, d3.js, and Nix+PHP environments. All findings marked with confidence levels accordingly. +**Confidence:** HIGH (based on direct source analysis of existing plugin code and Kimai's KimaiFormSelect internals) ## Critical Pitfalls Mistakes that cause rewrites or major issues. -### Pitfall 1: Kimai Major Version Breaking Changes in Plugin API +### Pitfall 1: KimaiFormSelect Depends on Kimai's Plugin Container -- Cannot Use It Standalone + +**What goes wrong:** SEED-001 suggests using Kimai's `KimaiFormSelect.js` with `data-api-url`, `data-related-select`, and `data-empty-url` attributes to get cascading for free. But `KimaiFormSelect._activateApiSelects()` calls `this.getContainer().getPlugin('api')` to make API requests (line 433-435). This requires the select to be inside a form that Kimai's JS plugin system initialized via `activateForm()`. The heatmap widget is a dashboard card, not a Symfony form. + +**Why it happens:** Kimai's form system (`KimaiFormSelect`, `KimaiFormPlugin`) is tightly coupled to Kimai's JS plugin container and form lifecycle. The cascading logic in `_activateApiSelects` registers a delegated `change` event listener on `document`, but the handler calls `this.getContainer().getPlugin('api')` which requires the KimaiFormSelect instance to have been created by Kimai's app initialization. Our widget's IIFE bundle runs independently. + +**Consequences:** Adding `data-api-url` and `data-related-select` attributes to `` elements styled with Tabler CSS. Add TomSelect only if the UX demands it. + +**Detection:** Change the customer picker and observe whether the project picker updates. If it does not, you hit this. + +### Pitfall 2: Destroying and Recreating SVG Without Cleaning Up Tooltips + +**What goes wrong:** The current `renderHeatmap()` starts with `container.innerHTML = ''` (line 184) but tooltips are appended to `document.body` (line 267). When switching visualization modes (year -> week -> day), each render creates a new tooltip. The existing cleanup at line 263 (`document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove())`) only runs inside `renderHeatmap` -- if new modes have their own render functions without this cleanup, tooltips accumulate. + +**Why it happens:** The tooltip is necessarily outside the SVG container (fixed positioning to escape overflow clipping). Each render function must know to clean up the shared tooltip. + +**Consequences:** Multiple `.heatmap-tooltip` divs on the page. Stale tooltip positioning. Memory leaks on repeated mode switches. + +**Prevention:** Extract tooltip into a shared module that all visualization modes import. One tooltip div, created on first use, reused across mode switches. A single `cleanup()` function that any render path calls before rebuilding. + +**Detection:** Switch modes 5+ times, inspect `document.body` children for orphaned `.heatmap-tooltip` elements. + +### Pitfall 3: Hour-Level Aggregation Query Performance + +**What goes wrong:** Day-mode and combined day/hour views need time-of-day data. The current `getDailyAggregation()` groups by `DATE(t.date)` which benefits from Kimai's date index. An hour-level query (`HOUR(t.begin)` or `EXTRACT(HOUR FROM t.begin)`) applies a function to the column, defeating index usage. GROUP BY HOUR over a full year of data scans every row for that user. + +**Why it happens:** MySQL/MariaDB cannot use an index when a function wraps the indexed column. + +**Consequences:** Slow queries for users with thousands of entries. For personal use (likely <10K entries) it is noticeable latency (~200-500ms), not catastrophic. But it makes mode switching feel sluggish. -**What goes wrong:** Kimai 2.x has changed its internal plugin API, event system, and widget rendering between releases without a formal deprecation cycle. A plugin built against one version silently breaks on the next. The `WidgetInterface`, dashboard rendering hooks, and Twig extensions have all shifted. -**Why it happens:** Kimai is maintained primarily by one developer (Kevin Papst). The plugin API is not versioned independently from the application -- it evolves with Kimai core. -**Consequences:** Plugin stops rendering, throws Symfony container errors, or silently disappears from the dashboard after a Kimai update. **Prevention:** -- Pin to a specific Kimai version in composer.json (`"kimai/kimai": "^2.x"` with a tight constraint) -- Read UPGRADING.md in the Kimai repo before each Kimai update -- Write an integration test that boots the Symfony kernel with the plugin loaded -- this catches container/DI breakage immediately -- Subscribe to Kimai releases (GitHub watch) for breaking change awareness -**Detection:** Dashboard widget silently missing after update. Symfony cache clear errors. DI container compilation failures. -**Confidence:** MEDIUM (based on Kimai's development history through early 2025) -**Phase relevance:** Phase 1 (scaffold) -- lock version constraints early. +1. Limit hour-level queries to a narrow date range (one week or one month, not a full year). The UI for day-mode should show a specific date range anyway. +2. For the combined matrix, fetch raw timesheet entries for the selected range and aggregate in PHP rather than doing a complex GROUP BY. +3. Profile with `EXPLAIN` on the actual query against the dev database. -### Pitfall 2: Timezone Mismatch Between PHP and JavaScript - -**What goes wrong:** Kimai stores timestamps in UTC in the database. The PHP backend converts to the user's configured timezone for display. If your d3.js heatmap receives raw UTC timestamps and bins them into days client-side, days land in the wrong cells. A session logged at 23:30 Berlin time (21:30 UTC) shows up on the correct day in Kimai's timesheet but the previous day in your heatmap. -**Why it happens:** The boundary between "server renders timezone-aware data" and "client renders timezone-aware data" is easy to get wrong, especially when aggregating by day. -**Consequences:** Hours appear on wrong days. Totals per day are wrong. User loses trust in the widget immediately. -**Prevention:** -- Aggregate by day on the PHP side using the user's configured Kimai timezone (`$user->getTimezone()`) -- Send pre-aggregated `{date: "2026-04-08", hours: 5.5, count: 3}` to the frontend -- never raw timestamps -- The d3 heatmap should receive date strings (not timestamps) so no further timezone conversion happens client-side -**Detection:** Compare heatmap day totals against Kimai's built-in weekly/monthly reports. Discrepancies = timezone bug. -**Confidence:** HIGH (this is a universal time-tracking visualization issue, well-documented across domains) -**Phase relevance:** Phase 1 (data layer) -- get the aggregation right before building the visualization. - -### Pitfall 3: Kimai Widget System Assumptions - -**What goes wrong:** Kimai's dashboard widget system expects widgets to implement specific interfaces and register via Symfony service tags. Developers coming from generic Symfony bundle development wire things up as controllers/routes instead of using the widget system, resulting in a working page but not a dashboard widget. -**Why it happens:** Kimai's widget system is Kimai-specific, not standard Symfony. Documentation is sparse. Developers cargo-cult from Symfony controller tutorials instead of studying existing Kimai plugins. -**Consequences:** You build a standalone page that works at `/my-heatmap` but cannot embed in the Kimai dashboard. Rework required to fit the widget interface. -**Prevention:** -- Study existing Kimai plugins that provide dashboard widgets (e.g., `kimai/CalendarBundle`, built-in widgets in `src/Widget/`) -- Implement `WidgetInterface` (or extend `AbstractWidget`) from the start -- Register as a tagged Symfony service: `kimai.widget` -- Do not create a standalone controller -- the widget renders within the dashboard's Twig template -**Detection:** Widget does not appear on the dashboard. No errors, just absent. -**Confidence:** MEDIUM (based on Kimai source structure through 2024-2025) -**Phase relevance:** Phase 1 (scaffold) -- get the widget rendering on the dashboard before writing any d3 code. +**Detection:** Mode switch to day-view takes noticeably longer than year-view load. ## Moderate Pitfalls -### Pitfall 4: d3.js Bundle Size in Kimai's Asset Pipeline +### Pitfall 4: Mode State Lost on Filter Change -**What goes wrong:** Including all of d3.js (~500KB minified) for a calendar heatmap that only needs `d3-scale`, `d3-selection`, `d3-time`, and `d3-time-format`. Kimai uses Webpack Encore for asset compilation, and a full d3 import bloats the plugin's JS bundle unnecessarily. -**Why it happens:** `import * as d3 from 'd3'` is the most common tutorial pattern. -**Prevention:** -- Import only needed d3 modules: `import { select } from 'd3-selection'; import { scaleQuantize } from 'd3-scale';` -- This reduces the d3 footprint to ~50-80KB -- Verify with Webpack Bundle Analyzer that tree-shaking works -**Confidence:** HIGH (well-documented d3 best practice) -**Phase relevance:** Phase 2 (visualization) -- set up imports correctly from the first d3 code. +**What goes wrong:** User switches to week-mode, then changes the project filter. The filter's fetch callback re-renders the visualization, but defaults back to year-mode because the mode selection is not part of the render state. The current `doRender()` function (line 339-344) always calls `renderHeatmap()` -- the year view. -### Pitfall 5: Nix + PHP + Composer Dev Environment Complexity +**Why it happens:** The current architecture has no shared state object. Mode and filter are independent UI controls that both trigger re-renders, but neither knows about the other's state. -**What goes wrong:** Getting a working Kimai instance inside a Nix devshell is non-trivial. Kimai requires PHP 8.1+, specific PHP extensions (intl, gd, mbstring, zip, xml), MySQL/MariaDB or SQLite, and Composer. Nix's PHP packaging sometimes has extension version mismatches, and Kimai's `composer install` may try to download packages that conflict with Nix's hermetic approach. -**Why it happens:** PHP ecosystem tooling (Composer, PECL extensions) assumes a mutable system. Nix is immutable. These philosophies clash in subtle ways -- e.g., Composer's `post-install-cmd` scripts that try to write to vendor directories, or PHP extensions that need specific system libraries. -**Consequences:** Days lost fighting the dev environment before writing any plugin code. Temptation to abandon Nix and use Docker instead. -**Prevention:** -- Use `pkgs.php83` (or latest) with extensions via `php.withExtensions` -- Use SQLite for the dev database (simpler than spinning up MariaDB in Nix) -- Consider a hybrid approach: Nix provides PHP + Composer + Node, but let Composer manage vendor/ normally (don't try to Nixify Composer dependencies) -- Set `COMPOSER_HOME` to a writable temp directory -- Pre-seed the database with a SQL fixture, not Kimai's interactive installer -- Test the devshell setup as the very first task -- do not proceed to plugin code until `bin/console kimai:version` works -**Detection:** `nix develop` fails to start, or Kimai throws "extension missing" errors at runtime. -**Confidence:** MEDIUM (based on general Nix+PHP experience; Kimai-specific Nix setup is uncommon) -**Phase relevance:** Phase 0 / Pre-phase -- this blocks everything else. Must be solved first. +**Consequences:** User frustration. Every filter change resets the view mode. Feels broken. -### Pitfall 6: d3.js SVG Performance with 365+ Day Cells +**Prevention:** Introduce a state object BEFORE adding the first new mode: +```typescript +interface WidgetState { + mode: 'year' | 'week' | 'day' | 'combined'; + metric: 'hours' | 'count'; + filters: { customerId: number | null; projectId: number | null; activityId: number | null }; + data: HeatmapData | null; +} +``` +All UI actions (mode switch, filter change, metric toggle) update state, then call a single `render(state)` dispatcher that picks the right visualization function. -**What goes wrong:** Rendering 365 individual `` elements in SVG is fine. But adding tooltips, hover effects, and click handlers to each cell via d3's `.on()` creates 365+ event listeners. Combined with CSS transitions, this can cause jank on lower-end machines or when the widget shares the dashboard with other widgets. -**Why it happens:** The naive approach of binding events per-element is the d3 tutorial default. -**Prevention:** -- Use event delegation: attach a single mousemove/click listener to the SVG container, use `document.elementFromPoint()` or d3's `pointer()` + geometric lookup to identify the hovered cell -- Alternatively, for 365 cells, individual listeners are actually fine in modern browsers -- only becomes a real problem at 1000+ elements. So: measure first, optimize only if dashboard feels sluggish -- Avoid CSS `transition` on all 365 rects simultaneously (e.g., on theme change) -**Detection:** Dashboard sluggishness. DevTools Performance tab shows long "Recalculate Style" times. -**Confidence:** HIGH (well-understood SVG performance characteristics) -**Phase relevance:** Phase 2 (visualization) -- implement event delegation from the start if supporting multi-year views, otherwise defer optimization. +**Phase warning:** This refactor MUST happen before adding any new visualization mode. Retrofitting state management after multiple modes exist is painful and error-prone. -### Pitfall 7: Kimai's Webpack Encore Integration for Plugins +### Pitfall 5: Cascading API Response Format Assumptions -**What goes wrong:** Kimai plugins must integrate with Kimai's existing Webpack Encore setup, not ship their own. Developers create a standalone webpack.config.js, build separately, and the assets don't load because Kimai's asset manifest doesn't know about them. -**Why it happens:** Symfony Encore documentation describes standalone setup. Kimai's plugin asset pipeline is plugin-specific and less documented. -**Prevention:** -- Follow Kimai's plugin asset conventions: assets go in `Resources/public/` or use `assets/` with an `encore` entry -- Study how existing Kimai plugins (e.g., CustomCSS, ExpensesBundle) register their JS/CSS -- Kimai 2.x may use its own asset inclusion mechanism via Twig blocks in widget templates rather than Encore entries -- verify against the version you target -- For d3.js, consider whether you even need Encore: a single pre-built JS file in `Resources/public/` that the widget template includes via ` -{% endblock %} ``` -Pass data as a JSON data attribute. The JS reads it and renders the SVG. This avoids API calls and works with Kimai's server-side rendering model. +### Display Toggle (Hours vs Entry Count) -**Confidence: LOW** -- The exact Twig block names and asset path conventions should be verified against a working Kimai plugin. The `@theme/widget.html.twig` base template may differ. +Same pattern -- `btn-group` or a simple toggle. The data is already in `DayEntry` (`hours` and `count` fields). This is purely a frontend change to switch which field drives the color scale. -## Alternatives Considered +## What NOT to Add -| Category | Recommended | Alternative | Why Not | -|----------|-------------|-------------|---------| -| JS Visualization | d3 sub-modules | cal-heatmap | Limits customization for Kimai theme integration, click navigation, and mode toggling | -| JS Visualization | d3 sub-modules | Chart.js matrix | Less control over calendar layout, weaker SVG customization | -| JS Bundler | esbuild | Webpack Encore | Overkill for bundling a single widget's JS. Encore adds Symfony config overhead. | -| JS Testing | Vitest | Jest | d3 v7 is ESM-only; Jest's ESM support requires workarounds | -| JS Language | TypeScript | Plain JS | Type safety for the d3 code, catches data shape mismatches at build time | -| PHP Testing | PHPUnit | Pest | Kimai's own tests use PHPUnit. Consistency with the host app matters. | -| Dev Env | Nix flake | Docker Compose | User runs NixOS; Nix is native. Docker adds a layer. | +| Temptation | Why Not | +|------------|---------| +| `d3-axis` | The new modes don't need formal axes. Simple text labels (like the existing month/day labels) are sufficient and lighter. | +| `d3-shape` | We draw rectangles with ``, not path generators. | +| `d3-transition` | Animations between mode switches would be nice but add complexity. Defer to polish. | +| `chart.js` or `cal-heatmap` | Same rationale as v1.0 -- raw d3 gives us full control over Kimai theme integration. | +| TomSelect CSS bundle | Already loaded by Kimai's Sass pipeline. Bundling it would cause style conflicts. | +| Kimai's `KimaiFormSelect.js` | Depends on `KimaiContainer` plugin system. Our widget is standalone IIFE. | +| `luxon` or `date-fns` | d3-time and d3-time-format handle all our date math. No need for another date library. | -## TypeScript for d3 +## Updated Type Definitions -Use TypeScript for the heatmap source code. d3 v7 ships with type definitions. TypeScript catches common d3 mistakes (wrong scale types, missing data fields) at build time. esbuild handles `.ts` natively. +New types needed in `types.ts`: -```json -// tsconfig.json (minimal) -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - "types": ["vitest/globals"] - }, - "include": ["assets/src/**/*.ts", "assets/test/**/*.ts"] +```typescript +// Visualization modes +type HeatmapMode = 'year' | 'week' | 'day' | 'combined'; + +// Display metric toggle +type DisplayMetric = 'hours' | 'count'; + +// Hourly data for day/combined modes +interface HourEntry { + hour: number; // 0-23 + dayOfWeek: number; // 0-6 (Monday=0) + hours: number; + count: number; +} + +// Weekday aggregation for week mode +interface WeekdayEntry { + dayOfWeek: number; // 0-6 + hours: number; + count: number; +} + +// Entity picker options +interface CustomerOption { + id: number; + name: string; +} + +interface ActivityOption { + id: number; + name: string; } ``` ## Installation Commands ```bash -# PHP dependencies (in the plugin directory) -composer init --name="kimai/heatmap-bundle" --type="kimai-plugin" -# No extra PHP packages needed beyond Kimai's own dependencies +# New runtime dependency +npm install tom-select@^2.4.3 -# JS dependencies -npm init -y -npm install d3-scale d3-selection d3-time d3-time-format d3-scale-chromatic d3-shape -npm install -D typescript esbuild vitest jsdom @types/d3-scale @types/d3-selection @types/d3-time @types/d3-time-format @types/d3-scale-chromatic @types/d3-shape +# New dev dependency (check if types ship with tom-select itself first) +npm install -D @types/tom-select ``` -## Nix Flake Approach +No new PHP/Composer dependencies required. -The flake should provide: -1. **PHP 8.2** with extensions: `mbstring`, `intl`, `pdo_mysql` (or `pdo_sqlite` for dev), `xml`, `zip` -2. **Composer 2.x** -3. **Node.js 22 LTS** with npm -4. **MariaDB** or **SQLite** for the local Kimai DB -5. A **devShell** script that clones Kimai, runs migrations, seeds test data, and symlinks the plugin into `var/plugins/` +## esbuild Consideration -SQLite is recommended for local dev to avoid running a separate DB server. Kimai supports SQLite out of the box. +TomSelect's package includes CSS files. When importing from `tom-select`, esbuild may try to bundle CSS. Since Kimai already loads TomSelect CSS, import only the JS entry point: -**Confidence: HIGH** for the Nix approach concept. The specific Kimai setup commands need verification. +```typescript +import TomSelect from 'tom-select/dist/js/tom-select.complete'; +``` -## Key Version Verification Checklist - -Before starting development, verify these against current sources: - -- [ ] Kimai latest stable version (check GitHub releases) -- [ ] Kimai's required PHP version (check `composer.json`) -- [ ] Kimai's Symfony version (check `composer.lock`) -- [ ] Kimai's `WidgetInterface` API (check `src/Widget/` in Kimai source) -- [ ] Kimai's Twig widget template blocks (check existing dashboard widgets in Kimai source) -- [ ] Kimai's asset serving mechanism for plugins (may use Symfony AssetMapper now instead of Encore) -- [ ] d3 v7 latest patch version on npm +This avoids CSS duplication and style conflicts. ## Sources -- Kimai plugin documentation: https://www.kimai.org/documentation/plugin-development.html -- Kimai GitHub: https://github.com/kimai/kimai -- d3.js documentation: https://d3js.org/ -- d3 calendar heatmap examples: https://observablehq.com/@d3/calendar -- Vitest documentation: https://vitest.dev/ -- esbuild documentation: https://esbuild.github.io/ - -**All sources are from training data, not live-fetched. Confidence levels reflect this limitation.** +- Kimai TomSelect integration: `dev/kimai/assets/js/forms/KimaiFormSelect.js` (local source, verified) +- Kimai webpack config: `dev/kimai/webpack.config.js` (local source, confirmed no TomSelect global exposure) +- Kimai API permissions: `dev/kimai/src/API/ProjectController.php` (local source, `#[IsGranted('API')]`) +- Kimai API cascading pattern: `dev/kimai/src/Form/Extension/SelectWithApiDataExtension.php` (local source) +- Kimai package.json: `dev/kimai/package.json` (local source, `tom-select: ^2.4.3`) +- [tom-select on npm](https://www.npmjs.com/package/tom-select) -- v2.5.2 latest, ~16KB gzipped +- [tom-select v2.5.2 on Bundlephobia](https://bundlephobia.com/package/tom-select) -- bundle size analysis +- [TomSelect documentation](https://tom-select.js.org/) diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md index a002453..c00959f 100644 --- a/.planning/research/SUMMARY.md +++ b/.planning/research/SUMMARY.md @@ -1,150 +1,161 @@ # Project Research Summary -**Project:** Kimai Heatmap Plugin -**Domain:** Time-tracking dashboard widget (Symfony bundle + d3.js visualization) +**Project:** Kimai Heatmap Plugin v1.1 (Modes & Filtering) +**Domain:** Time-tracking dashboard visualization -- multi-mode heatmap with cascading entity filters **Researched:** 2026-04-08 -**Confidence:** MEDIUM +**Confidence:** HIGH ## Executive Summary -This is a Kimai 2.x plugin that adds a GitHub-style activity heatmap to the dashboard. The product pattern is well-established: calendar grid, color intensity for activity levels, tooltips, click-through navigation. The technology choices are straightforward -- a Symfony bundle for the backend (matching Kimai's framework), d3.js sub-modules for the SVG visualization, TypeScript for type safety, and esbuild for bundling. No additional database is needed; the plugin reads from Kimai's existing timesheet table. +v1.1 adds four visualization modes (year, week, day-of-week, day/hour combined matrix), cascading entity pickers (customer/project/activity), and an hours-vs-count display toggle to the existing heatmap widget. The existing stack (PHP 8.2, Symfony 6.4, d3 v7, TypeScript, esbuild) handles everything. The only new dependency is TomSelect for entity pickers -- and even that should be deferred until the final phase. The core work is a rendering architecture refactor (strategy pattern for modes), three new backend aggregation queries, and a state management object to coordinate filters, modes, and display metric. -The recommended approach is a strict 4-phase build: plugin scaffold first (prove the widget appears on the dashboard), then data layer (aggregation API), then visualization (d3 heatmap rendering), then interactivity (filters, toggles, click navigation). This ordering is dictated by hard dependencies -- you cannot render a heatmap without data, and you cannot build data queries without a working plugin scaffold. The Nix dev environment setup is a prerequisite phase that should be timeboxed to one day. +The recommended approach is to refactor the monolithic `renderHeatmap()` into a mode-dispatched renderer system BEFORE adding any new visualization. This is the critical sequencing insight: state management and the renderer interface must exist before the first new mode lands, or every subsequent mode will be a retrofit nightmare. Week-mode (day-of-week aggregation) should come first because it needs zero backend changes -- it aggregates existing daily data client-side -- which validates the renderer architecture cheaply. Entity pickers come last because they are the most complex integration point and everything else works fine with the existing project dropdown. -The primary risks are: (1) Kimai's plugin API is under-documented and shifts between releases -- the widget interface, DI tags, and asset conventions all need verification against the target Kimai version before writing code; (2) timezone-incorrect day aggregation will produce wrong data silently -- aggregation must happen server-side in the user's configured timezone; (3) the Nix + PHP + Kimai dev environment setup can consume days if not timeboxed. All three are manageable with the mitigations outlined below. +The primary risks are: (1) accidentally coupling to Kimai's internal KimaiFormSelect.js system, which silently fails outside Kimai's form lifecycle; (2) tooltip/DOM cleanup leaks when switching between modes; and (3) TomSelect bundle duplication. All three are well-understood and have clear preventions documented in the research. The hour-level aggregation queries (day/combined modes) are the only performance concern -- they defeat index usage -- but for personal use with <10K entries this is acceptable latency, not a blocker. ## Key Findings ### Recommended Stack -The plugin is a standard Symfony bundle running inside Kimai 2.x. The frontend is a self-contained d3.js visualization bundled with esbuild and shipped as a single JS file in `Resources/public/`. No integration with Kimai's Webpack Encore build is needed or desired. +No new PHP/Composer dependencies. One new npm dependency: `tom-select@^2.4.3` (only when entity pickers are implemented). All four visualization modes use existing d3 sub-modules -- no new d3 packages needed. `d3-shape` was listed in v1.0 research but was never needed and should not be added. -**Core technologies:** -- **PHP 8.2+ / Symfony 6.4 LTS**: Must match Kimai's runtime exactly. Verify against Kimai's `composer.json`. -- **d3.js sub-modules** (d3-scale, d3-selection, d3-time, d3-time-format, d3-scale-chromatic, d3-shape): Selective imports keep bundle size to ~50-80KB vs ~500KB for full d3. -- **TypeScript**: d3 v7 ships types. Catches data shape bugs at build time. esbuild handles `.ts` natively. -- **esbuild**: Single-command bundling of d3 modules into one JS file. Simpler than Webpack Encore for a single widget. -- **Vitest + jsdom**: ESM-native testing for d3 code. Jest struggles with d3 v7's ESM-only modules. -- **PHPUnit**: Matches Kimai's own test framework. -- **Nix flake**: Reproducible dev env with PHP 8.2, Composer, Node 22, SQLite. +**New additions only:** +- **TomSelect ^2.4.3**: Cascading entity pickers -- matches Kimai's own version for visual consistency. Bundle JS only (Kimai already loads TomSelect CSS globally). Defer to final phase. +- **Tabler btn-group**: Mode switcher and display toggle UI -- already loaded by Kimai, zero cost. + +**Explicitly rejected:** cal-heatmap, d3-axis, d3-transition, d3-shape, luxon/date-fns, TomSelect CSS bundle, Kimai's KimaiFormSelect.js. ### Expected Features **Must have (table stakes):** -- Calendar grid layout (weeks x days) with color intensity mapping -- Tooltip on hover showing date, hours, entry count -- Day-of-week and month labels -- Empty state rendering (no-data days visible, not invisible) -- Click-through to Kimai timesheet filtered by date -- Kimai theme integration via CSS variables -- Default trailing 12-month range +- Mode switcher UI (segmented control) with year + week modes +- Hours/count display toggle (data already present, purely frontend) +- Activity filtering (backend `?activity=N` param) +- Cascading entity pickers (customer -> project -> activity) +- Persistent filter/mode state across re-renders **Should have (differentiators):** -- Toggle between hours/day and entry count (different questions answered) -- Project/activity filter dropdown -- Configurable time range (3/6/12 months) -- Streak indicator and summary stats row +- Day-of-week mode (week view) -- which weekdays are busiest +- Time-of-day mode (day view) -- when during the day work happens +- Combined day/hour matrix -- full punchcard (7x24 grid) +- Color scale legend +- Customer-level filtering -**Defer indefinitely:** -- Hour-of-day matrix, export/share, goal setting, animations, multi-user, mobile layout, custom color picker -- all anti-features for a personal tracking widget. +**Defer (v2+):** +- Configurable date range selector +- Animated transitions between modes +- Drag-to-select, drill-down charts, export/share +- Multi-user comparison, real-time refresh, custom color themes ### Architecture Approach -The architecture follows a clean separation: thin Twig template (HTML shell + controls), XHR data fetching from a dedicated API controller, and client-side d3 rendering. The widget registers via Kimai's `WidgetInterface` + `kimai.widget` DI tag. Data flows from Kimai's timesheet table through a `HeatmapService` (Doctrine QueryBuilder, GROUP BY date, user-scoped), to a `HeatmapController` (JSON API), to the d3 module (SVG rendering). +Refactor the monolithic `renderHeatmap()` into a strategy-pattern mode system. A `ModeController` dispatches to mode-specific renderers (`year.ts`, `week.ts`, `day.ts`, `combined.ts`) that share extracted utilities (tooltip, color scale, cell click handler). A centralized `HeatmapState` object tracks mode, display metric, filters, and cached data. All UI changes (mode switch, filter change, metric toggle) update state then call a unified render dispatcher. The display toggle (hours/count) re-renders from cached data without a new fetch; all other changes trigger a fetch. **Major components:** -1. **KimaiHeatmapBundle** -- Symfony bundle class implementing `PluginInterface`, auto-discovered by Kimai -2. **HeatmapWidget** -- `WidgetInterface` implementation, registers on dashboard, renders Twig template -3. **HeatmapController** -- API endpoint (`/api/plugins/heatmap/data`) returning aggregated JSON -4. **HeatmapService** -- Server-side aggregation (hours/count per day, timezone-aware, user-scoped) -5. **d3 heatmap module** -- TypeScript, calendar grid rendering, event handling, theme integration +1. **ModeController** -- mode switching, data fetching orchestration, state management +2. **Renderers (year/week/day/combined)** -- mode-specific layout logic implementing a shared `ModeRenderer` interface +3. **Shared utilities (tooltip, colorScale)** -- extracted from current `renderHeatmap()`, reused by all modes +4. **Filter bar (filters.ts)** -- TomSelect entity pickers with cascade logic, independent of Kimai's form system +5. **HeatmapService (PHP)** -- three new aggregation methods (`getWeekdayAggregation`, `getHourlyAggregation`, `getCombinedAggregation`) +6. **HeatmapController (PHP)** -- `mode` and `activity` query params, custom cascade endpoints ### Critical Pitfalls -1. **Kimai plugin API instability** -- Pin to a specific Kimai version. Write an integration test that boots the Symfony kernel with the plugin loaded. Subscribe to Kimai releases for breaking change awareness. -2. **Timezone mismatch (PHP vs JS day boundaries)** -- Aggregate by day on the PHP side using `$user->getTimezone()`. Send date strings to the frontend, never raw timestamps. Compare output against Kimai's own reports. -3. **Widget system misunderstanding** -- Use `WidgetInterface` + `kimai.widget` DI tag from the start. Do not build a standalone controller page. Study existing Kimai widgets before coding. -4. **Asset loading failures** -- Ship prebuilt JS in `Resources/public/`, loaded via `