chore: archive v1.0 milestone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christopher Mühl 2026-04-08 23:25:26 +02:00
parent a074e41e81
commit 244c7c66fc
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
34 changed files with 1706 additions and 214 deletions

20
.planning/MILESTONES.md Normal file
View file

@ -0,0 +1,20 @@
# Milestones
## v1.0 MVP (Shipped: 2026-04-08)
**Phases completed:** 5 phases, 11 plans
**Stats:** 67 files, ~10k insertions, ~650 LOC source (PHP + TypeScript), ~730 LOC tests
**Key accomplishments:**
- Nix devshell with process-compose orchestrating MariaDB + Kimai dev server
- HeatmapService with timezone-correct daily aggregation and PHPUnit tests
- d3.js calendar heatmap with color scale, labels, tooltips, and Kimai theme integration
- Click-through navigation to Kimai timesheet + project filter dropdown
- Streak indicator, summary stats, weekend styling, and week-start preference
- 30+ tests across PHPUnit and Vitest
**Known gaps:**
- INTR-01 partial: activity filtering descoped (project filter only)
---

View file

@ -2,7 +2,7 @@
## What This Is
A Kimai dashboard widget that displays a GitHub-style activity heatmap visualizing time tracking data. Built as a Symfony bundle plugin for personal time tracking use, rendering an interactive d3.js calendar heatmap on the Kimai dashboard.
A Kimai dashboard widget that displays a GitHub-style activity heatmap visualizing time tracking data. Built as a Symfony bundle plugin for personal time tracking, rendering an interactive d3.js calendar heatmap with click-through navigation, project filtering, streak tracking, and summary stats.
## Core Value
@ -12,36 +12,36 @@ At a glance, see where your time went — a visual map of tracking activity that
### Validated
(None yet — ship to validate)
- ✓ Dashboard widget renders a d3.js calendar heatmap of time entries — v1.0
- ✓ Filterable by project — v1.0
- ✓ Clicking a day cell navigates to Kimai's timesheet view filtered to that day — v1.0
- ✓ Color scheme uses Kimai's theme CSS variables — v1.0 (partial: tooltip/labels/empty use CSS vars, color scale hardcoded green)
- ✓ Nix flake/devshell provides a local Kimai instance with seeded database — v1.0
- ✓ PHPUnit tests for backend (API, data aggregation) — v1.0
- ✓ JavaScript tests for the d3 heatmap component — v1.0
- ✓ Streak indicator, summary stats, weekend styling — v1.0
### Active
- [ ] Dashboard widget renders a d3.js calendar heatmap of time entries
- [ ] Heatmap cells toggle between hours-per-day and entry-count display
- [ ] Configurable time range (not locked to a single preset)
- [ ] Filterable by project and/or activity
- [ ] Clicking a day cell navigates to Kimai's timesheet view filtered to that day
- [ ] Color scheme uses Kimai's theme variables
- [ ] Nix flake/devshell provides a local Kimai instance with seeded database
- [ ] PHPUnit tests for backend (API, data aggregation)
- [ ] JavaScript tests for the d3 heatmap component
- [ ] Activity filtering (project filter shipped, activity deferred)
### Out of Scope
- Billable hours tracking features — this is for personal time tracking
- Billable hours tracking features — personal time tracking only
- Mobile-specific layout — desktop Kimai dashboard only
- Export/sharing of heatmap images
- Export/sharing of heatmap images — no audience for personal use
- Real-time updates — refresh on page load is fine
- Custom color theme picker — Kimai theme integration is sufficient
- Hour-of-day matrix — different visualization, scope creep
- Multi-user comparison — personal tracking tool
## Context
- Kimai is a self-hosted time tracking application built on Symfony
- Plugins are Symfony bundles, installable via composer
- The user runs Kimai for personal time tracking, not billable client work
- d3.js is the visualization library of choice for the heatmap
- Development follows TDD — tests written first to prevent AI-generated regressions
- Local development uses a Nix flake/devshell with a running Kimai instance and seeded test data
- The user's infra runs on NixOS
Shipped v1.0 with ~650 LOC source (PHP + TypeScript) and ~730 LOC tests.
Tech stack: Symfony bundle (PHP 8.2), d3.js v7 (TypeScript), esbuild, Vitest, PHPUnit.
Dev environment: Nix flake with process-compose, MariaDB 11.4, Kimai 2.52.0.
## Constraints
@ -54,28 +54,14 @@ At a glance, see where your time went — a visual map of tracking activity that
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| d3.js for visualization | Flexible, well-suited for calendar heatmaps, user preference | — Pending |
| Symfony bundle (not local plugin) | Standard Kimai plugin distribution, composable | — Pending |
| Kimai theme colors (not custom) | Visual consistency with the rest of the dashboard | — Pending |
| TDD with PHPUnit + JS tests | Prevent regressions from AI-generated code | — Pending |
| Nix flake for dev environment | Reproducible local Kimai instance, matches user's NixOS infra | — Pending |
## Evolution
This document evolves at phase transitions and milestone boundaries.
**After each phase transition** (via `/gsd-transition`):
1. Requirements invalidated? → Move to Out of Scope with reason
2. Requirements validated? → Move to Validated with phase reference
3. New requirements emerged? → Add to Active
4. Decisions to log? → Add to Key Decisions
5. "What This Is" still accurate? → Update if drifted
**After each milestone** (via `/gsd-complete-milestone`):
1. Full review of all sections
2. Core Value check — still the right priority?
3. Audit Out of Scope — reasons still valid?
4. Update Context with current state
| d3.js for visualization | Flexible, well-suited for calendar heatmaps | ✓ Good |
| Symfony bundle (not local plugin) | Standard Kimai plugin distribution | ✓ Good |
| Kimai theme CSS vars | Visual consistency with dashboard | ⚠️ Partial — color scale hardcoded green (Tabler lacks green scale vars) |
| TDD with PHPUnit + Vitest | Prevent regressions from AI-generated code | ✓ Good — 30+ tests |
| Nix flake for dev environment | Reproducible local Kimai instance | ✓ Good |
| IIFE format with KimaiHeatmap global | Browser compat without Kimai's Webpack Encore | ✓ Good |
| Ship prebuilt JS in Resources/public/ | Avoids hooking into Kimai's build pipeline | ✓ Good |
| Descope activity filtering | Project filter sufficient for personal use | ✓ Acceptable |
---
*Last updated: 2026-04-08 after initialization*
*Last updated: 2026-04-08 after v1.0 milestone*

View file

@ -0,0 +1,46 @@
# Retrospective
## Milestone: v1.0 — MVP
**Shipped:** 2026-04-08
**Phases:** 5 | **Plans:** 11
### What Was Built
- Nix devshell with process-compose orchestrating MariaDB + Kimai dev server
- HeatmapService with timezone-correct daily aggregation and PHPUnit tests
- d3.js calendar heatmap with color scale, labels, tooltips, Kimai theme integration
- Click-through navigation to Kimai timesheet + project filter dropdown
- Streak indicator, summary stats, weekend styling, week-start preference
- 30+ tests across PHPUnit and Vitest
### What Worked
- Single-day delivery from zero to shipped plugin — tight phase scoping kept momentum
- TDD approach caught real issues (bootstrap paths, final class mocking)
- Process-compose made the multi-service dev environment ergonomic
- IIFE bundle strategy avoided Kimai's Webpack Encore complexity entirely
### What Was Inefficient
- SUMMARY files not written for several phases (bookkeeping debt accumulated)
- REQUIREMENTS.md checkboxes fell out of sync (7/22 checked despite all shipped)
- Phase directory naming inconsistent (phase-2/, phase-3/ vs phases/01-dev-environment/, phases/04-heatmap-interaction/)
### Patterns Established
- Ship prebuilt JS in Resources/public/ — don't hook into host app build pipeline
- kimai.initialized event + javascriptRequest fallback for widget lifecycle
- Callback injection pattern for cell interaction (onCellClick)
- Flex wrapper layout for widget + sidebar controls
### Key Lessons
- Activity filtering was correctly descoped — project filter covers personal use
- Kimai's Tabler theme lacks green scale CSS vars, so hardcoded greens were pragmatic
- PHPUnit mocking requires non-final classes — caught early, would bite harder at scale
## Cross-Milestone Trends
| Metric | v1.0 |
|--------|------|
| Phases | 5 |
| Plans | 11 |
| Timeline | 1 day |
| Source LOC | ~650 |
| Test LOC | ~730 |

View file

@ -1,113 +1,28 @@
# Roadmap: Kimai Heatmap Plugin
## Overview
## Milestones
Build a Kimai dashboard widget that displays a GitHub-style activity heatmap of time tracking data. The path goes: reproducible dev environment, then prove the plugin loads and serves data, then render the heatmap visualization, then add interaction (click-through, filtering), then polish with stats and visual refinements. Tests are written alongside each feature phase (TDD), not deferred.
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-08) — [archive](milestones/v1.0-ROADMAP.md)
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
<details>
<summary>✅ v1.0 MVP (Phases 1-5) — SHIPPED 2026-04-08</summary>
Decimal phases appear between their surrounding integers in numeric order.
- [x] Phase 1: Dev Environment (2/2 plans) — completed 2026-04-08
- [x] Phase 2: Plugin Scaffold + Data Layer (2/2 plans) — completed 2026-04-08
- [x] Phase 3: Core Heatmap Rendering (3/3 plans) — completed 2026-04-08
- [x] Phase 4: Heatmap Interaction (2/2 plans) — completed 2026-04-08
- [x] Phase 5: Polish (2/2 plans) — completed 2026-04-08
- [x] **Phase 1: Dev Environment** - Nix flake with local Kimai instance and seeded test data
- [x] **Phase 2: Plugin Scaffold + Data Layer** - Symfony bundle, dashboard widget, aggregation API with PHPUnit tests
- [x] **Phase 3: Core Heatmap Rendering** - d3.js calendar grid with color mapping, labels, tooltips, theme integration, and JS tests
- [x] **Phase 4: Heatmap Interaction** - Click-through navigation, project/activity filtering, interaction tests (completed 2026-04-08)
- [x] **Phase 5: Polish** - Streak indicator, summary stats, weekend styling (completed 2026-04-08)
## Phase Details
### Phase 1: Dev Environment
**Goal**: Developer can run `nix develop` and have a working Kimai instance with test data, ready for plugin development
**Depends on**: Nothing (first phase)
**Requirements**: DEV-01, DEV-02, DEV-03
**Success Criteria** (what must be TRUE):
1. Running `nix develop` drops into a shell with PHP 8.2+, Composer, and Node available
2. A local Kimai instance starts and is accessible in the browser
3. The Kimai instance contains seeded time entry data spanning multiple months
4. A plugin directory is symlinked/mounted so code changes are reflected without reinstallation
**Plans**: phase-1/PLAN.md
Plans:
- [x] 01-01: Nix flake, process-compose, setup script, .gitignore
- [x] 01-02: Plugin scaffold + Kimai bootstrap verification
### Phase 2: Plugin Scaffold + Data Layer
**Goal**: Plugin is recognized by Kimai, shows a widget on the dashboard, and serves aggregated daily time data via API
**Depends on**: Phase 1
**Requirements**: PLUG-01, PLUG-02, PLUG-03, TEST-01, TEST-02
**Success Criteria** (what must be TRUE):
1. Kimai discovers the plugin and lists it in the plugin admin page
2. A widget placeholder appears on the Kimai dashboard
3. The API endpoint returns JSON with per-day aggregated hours and entry counts, correctly grouped by the user's timezone
4. PHPUnit tests pass for the data aggregation service and API endpoint
**Plans**: phase-2/PLAN.md
Plans:
- [x] 02-01: HeatmapService, PHPUnit tests, API controller
- [x] 02-02: Dashboard widget + template + DashboardSubscriber
### Phase 3: Core Heatmap Rendering
**Goal**: The dashboard widget renders a fully styled d3.js calendar heatmap from live Kimai data
**Depends on**: Phase 2
**Requirements**: HEAT-01, HEAT-02, HEAT-03, HEAT-04, HEAT-05, HEAT-06, HEAT-08, TEST-03
**Success Criteria** (what must be TRUE):
1. The widget displays a weeks-by-days calendar grid with cells colored by hours tracked (darker = more hours)
2. Hovering any cell shows a tooltip with date, hours, and entry count
3. Day-of-week labels appear on the Y-axis and month boundary labels along the top
4. Days with no tracked time render with a distinct "no data" color
5. Colors use Kimai's theme CSS variables (works with both light and dark themes)
**Plans**: phase-3/PLAN.md
**UI hint**: yes
Plans:
- [x] 03-01: JS toolchain — npm, esbuild, TypeScript, Vitest
- [x] 03-02: d3 calendar heatmap with color scale, labels, tooltips + Vitest tests
- [x] 03-03: Twig template integration + asset serving
### Phase 4: Heatmap Interaction
**Goal**: Users can click through to daily details and filter the heatmap by project or activity
**Depends on**: Phase 3
**Requirements**: HEAT-07, INTR-01, TEST-04
**Success Criteria** (what must be TRUE):
1. Clicking a day cell navigates to Kimai's timesheet view filtered to that specific date
2. A dropdown allows filtering the heatmap to show data for a single project or activity
3. Filtering updates the heatmap in place without a full page reload
4. JavaScript tests verify click navigation and tooltip interaction behavior
**Plans:** 2/2 plans complete
**UI hint**: yes
Plans:
- [x] 04-01: Day cell click navigation + project filter dropdown
- [x] 04-02: Vitest tests for click navigation and filter behavior
### Phase 5: Polish
**Goal**: The widget provides at-a-glance context beyond the heatmap itself -- streaks, stats, and visual cues for weekends
**Depends on**: Phase 4
**Requirements**: POLI-01, POLI-02, POLI-03
**Success Criteria** (what must be TRUE):
1. A streak indicator shows the current number of consecutive days with tracked time
2. A summary stats row displays total hours, average hours/day, and the busiest day
3. Weekend days are visually distinct from weekdays (subtle border or opacity difference)
**Plans**: phase-5/PLAN.md
**UI hint**: yes
Plans:
- [x] 05-01: Streak, stats, weekend styling, week-start preference
- [x] 05-02: Tests for polish features
</details>
## Progress
**Execution Order:**
Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Dev Environment | 2/2 | Done | 2026-04-08 |
| 2. Plugin Scaffold + Data Layer | 2/2 | Done | 2026-04-08 |
| 3. Core Heatmap Rendering | 3/3 | Done | 2026-04-08 |
| 4. Heatmap Interaction | 2/2 | Complete | 2026-04-08 |
| 5. Polish | 2/2 | Done | 2026-04-08 |
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Dev Environment | v1.0 | 2/2 | Complete | 2026-04-08 |
| 2. Plugin Scaffold + Data Layer | v1.0 | 2/2 | Complete | 2026-04-08 |
| 3. Core Heatmap Rendering | v1.0 | 3/3 | Complete | 2026-04-08 |
| 4. Heatmap Interaction | v1.0 | 2/2 | Complete | 2026-04-08 |
| 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 |

View file

@ -1,17 +1,17 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: verifying
stopped_at: Completed 04-02-PLAN.md
last_updated: "2026-04-08T13:35:22.113Z"
milestone_name: MVP
status: shipped
stopped_at: Milestone v1.0 complete
last_updated: "2026-04-08T21:30:00.000Z"
last_activity: 2026-04-08
progress:
total_phases: 5
completed_phases: 1
total_plans: 4
completed_plans: 3
percent: 75
completed_phases: 5
total_plans: 11
completed_plans: 11
percent: 100
---
# Project State
@ -21,75 +21,19 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-08)
**Core value:** At a glance, see where your time went -- a visual map of tracking activity that makes patterns obvious
**Current focus:** Phase 4 — Heatmap Interaction
**Current focus:** Planning next milestone
## Current Position
Phase: 4 (Heatmap Interaction) — EXECUTING
Plan: 2 of 2
Status: Phase complete — ready for verification
Phase: All v1.0 phases complete
Plan: N/A
Status: v1.0 shipped
Last activity: 2026-04-08
Progress: [██░░░░░░░░] 20%
## Performance Metrics
**Velocity:**
- Total plans completed: 2
- Average duration: ~15min
- Total execution time: ~30min
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| Phase 1 | 2 | ~30min | ~15min |
**Recent Trend:**
- Last 5 plans: -
- Trend: -
*Updated after each plan completion*
| Phase 2 P02-01 | 5min | 5 tasks | 8 files |
| Phase 03 P01 | 3min | 4 tasks | 7 files |
| Phase 03 P03 | 2min | 4 tasks | 1 files |
| Phase 04 P01 | 2min | 2 tasks | 7 files |
| Phase 04 P02 | 1min | 2 tasks | 2 files |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- [Roadmap]: TDD approach -- tests written alongside features, not in a separate phase
- [Roadmap]: Nix dev environment is Phase 1 prerequisite; everything else depends on it
- [Research]: Use Vitest + jsdom for JS tests (d3 v7 is ESM-only, Jest struggles)
- [Research]: Ship prebuilt JS in Resources/public/, do not hook into Kimai's Webpack Encore
- [Phase 1]: x86_64-linux only for Nix flake (NixOS-only project)
- [Phase 1]: MariaDB 11.4.9 on port 3307, Kimai on port 8010
- [Phase 1]: Plugin needs DI extension + services.yaml to register as tagged PluginInterface service
- [Phase 1]: Kimai's autoloader needs exclude-from-classmap for recursive plugin symlink
- [Phase 1]: PHP memory_limit=1G required for Kimai cache warmup
- [Phase 1]: process-compose API server disabled (-p 0) to avoid port 8080 conflict
- [Phase 2]: Removed final from HeatmapService to allow PHPUnit mocking
- [Phase 03]: IIFE format with KimaiHeatmap global for browser compat
- [Phase 03]: kimai.initialized event for deferred widget init, javascriptRequest fallback for AJAX
- [Phase 04]: Render project filter only when user has tracked projects
### Pending Todos
None yet.
### Blockers/Concerns
- [Research]: Kimai widget API (WidgetInterface, DI tags) needs verification against target Kimai version
Progress: [██████████] 100%
## Session Continuity
Last session: 2026-04-08T13:35:22.111Z
Stopped at: Completed 04-02-PLAN.md
Last session: 2026-04-08
Stopped at: Milestone v1.0 complete
Resume file: None

View file

@ -1,3 +1,12 @@
# Requirements Archive: v1.0 MVP
**Archived:** 2026-04-08
**Status:** SHIPPED
For current requirements, see `.planning/REQUIREMENTS.md`.
---
# Requirements: Kimai Heatmap Plugin
**Defined:** 2026-04-08

View file

@ -0,0 +1,113 @@
# Roadmap: Kimai Heatmap Plugin
## Overview
Build a Kimai dashboard widget that displays a GitHub-style activity heatmap of time tracking data. The path goes: reproducible dev environment, then prove the plugin loads and serves data, then render the heatmap visualization, then add interaction (click-through, filtering), then polish with stats and visual refinements. Tests are written alongside each feature phase (TDD), not deferred.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Dev Environment** - Nix flake with local Kimai instance and seeded test data
- [x] **Phase 2: Plugin Scaffold + Data Layer** - Symfony bundle, dashboard widget, aggregation API with PHPUnit tests
- [x] **Phase 3: Core Heatmap Rendering** - d3.js calendar grid with color mapping, labels, tooltips, theme integration, and JS tests
- [x] **Phase 4: Heatmap Interaction** - Click-through navigation, project/activity filtering, interaction tests (completed 2026-04-08)
- [x] **Phase 5: Polish** - Streak indicator, summary stats, weekend styling (completed 2026-04-08)
## Phase Details
### Phase 1: Dev Environment
**Goal**: Developer can run `nix develop` and have a working Kimai instance with test data, ready for plugin development
**Depends on**: Nothing (first phase)
**Requirements**: DEV-01, DEV-02, DEV-03
**Success Criteria** (what must be TRUE):
1. Running `nix develop` drops into a shell with PHP 8.2+, Composer, and Node available
2. A local Kimai instance starts and is accessible in the browser
3. The Kimai instance contains seeded time entry data spanning multiple months
4. A plugin directory is symlinked/mounted so code changes are reflected without reinstallation
**Plans**: phase-1/PLAN.md
Plans:
- [x] 01-01: Nix flake, process-compose, setup script, .gitignore
- [x] 01-02: Plugin scaffold + Kimai bootstrap verification
### Phase 2: Plugin Scaffold + Data Layer
**Goal**: Plugin is recognized by Kimai, shows a widget on the dashboard, and serves aggregated daily time data via API
**Depends on**: Phase 1
**Requirements**: PLUG-01, PLUG-02, PLUG-03, TEST-01, TEST-02
**Success Criteria** (what must be TRUE):
1. Kimai discovers the plugin and lists it in the plugin admin page
2. A widget placeholder appears on the Kimai dashboard
3. The API endpoint returns JSON with per-day aggregated hours and entry counts, correctly grouped by the user's timezone
4. PHPUnit tests pass for the data aggregation service and API endpoint
**Plans**: phase-2/PLAN.md
Plans:
- [x] 02-01: HeatmapService, PHPUnit tests, API controller
- [x] 02-02: Dashboard widget + template + DashboardSubscriber
### Phase 3: Core Heatmap Rendering
**Goal**: The dashboard widget renders a fully styled d3.js calendar heatmap from live Kimai data
**Depends on**: Phase 2
**Requirements**: HEAT-01, HEAT-02, HEAT-03, HEAT-04, HEAT-05, HEAT-06, HEAT-08, TEST-03
**Success Criteria** (what must be TRUE):
1. The widget displays a weeks-by-days calendar grid with cells colored by hours tracked (darker = more hours)
2. Hovering any cell shows a tooltip with date, hours, and entry count
3. Day-of-week labels appear on the Y-axis and month boundary labels along the top
4. Days with no tracked time render with a distinct "no data" color
5. Colors use Kimai's theme CSS variables (works with both light and dark themes)
**Plans**: phase-3/PLAN.md
**UI hint**: yes
Plans:
- [x] 03-01: JS toolchain — npm, esbuild, TypeScript, Vitest
- [x] 03-02: d3 calendar heatmap with color scale, labels, tooltips + Vitest tests
- [x] 03-03: Twig template integration + asset serving
### Phase 4: Heatmap Interaction
**Goal**: Users can click through to daily details and filter the heatmap by project or activity
**Depends on**: Phase 3
**Requirements**: HEAT-07, INTR-01, TEST-04
**Success Criteria** (what must be TRUE):
1. Clicking a day cell navigates to Kimai's timesheet view filtered to that specific date
2. A dropdown allows filtering the heatmap to show data for a single project or activity
3. Filtering updates the heatmap in place without a full page reload
4. JavaScript tests verify click navigation and tooltip interaction behavior
**Plans:** 2/2 plans complete
**UI hint**: yes
Plans:
- [x] 04-01: Day cell click navigation + project filter dropdown
- [x] 04-02: Vitest tests for click navigation and filter behavior
### Phase 5: Polish
**Goal**: The widget provides at-a-glance context beyond the heatmap itself -- streaks, stats, and visual cues for weekends
**Depends on**: Phase 4
**Requirements**: POLI-01, POLI-02, POLI-03
**Success Criteria** (what must be TRUE):
1. A streak indicator shows the current number of consecutive days with tracked time
2. A summary stats row displays total hours, average hours/day, and the busiest day
3. Weekend days are visually distinct from weekdays (subtle border or opacity difference)
**Plans**: phase-5/PLAN.md
**UI hint**: yes
Plans:
- [x] 05-01: Streak, stats, weekend styling, week-start preference
- [x] 05-02: Tests for polish features
## Progress
**Execution Order:**
Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Dev Environment | 2/2 | Done | 2026-04-08 |
| 2. Plugin Scaffold + Data Layer | 2/2 | Done | 2026-04-08 |
| 3. Core Heatmap Rendering | 3/3 | Done | 2026-04-08 |
| 4. Heatmap Interaction | 2/2 | Complete | 2026-04-08 |
| 5. Polish | 2/2 | Done | 2026-04-08 |

View file

@ -0,0 +1,142 @@
---
phase: 04-heatmap-interaction
verified: 2026-04-08T15:37:00Z
status: human_needed
score: 3/4 must-haves verified
gaps:
- truth: "A dropdown allows filtering the heatmap to show data for a single project or activity"
status: partial
reason: "Only project filtering is implemented. Activity filtering was explicitly descoped in 04-CONTEXT.md ('Projects only -- no activity filter') but the roadmap SC and INTR-01 both specify 'project or activity'. No later phase addresses this."
artifacts:
- path: "assets/src/heatmap.ts"
issue: "No activity filter option in dropdown, no activity query param in fetch"
- path: "Service/HeatmapService.php"
issue: "No getUserActivities() method"
- path: "Controller/HeatmapController.php"
issue: "No ?activity= query parameter handling"
missing:
- "getUserActivities() service method analogous to getUserProjects()"
- "Activity filter option in dropdown (or combined project/activity dropdown)"
- "Controller support for ?activity=N query parameter"
- "HeatmapService::getDailyAggregation() support for activityId parameter"
human_verification:
- test: "Click a heatmap day cell and verify navigation to Kimai timesheet filtered to that date"
expected: "Browser navigates to /en/timesheet/?daterange=YYYY-MM-DD%20-%20YYYY-MM-DD"
why_human: "Requires running Kimai instance with the plugin loaded to verify URL construction and Kimai's timesheet page accepting the daterange parameter"
- test: "Select a project from the filter dropdown and verify heatmap re-renders"
expected: "Heatmap cells update to show only data for the selected project, color scale adjusts"
why_human: "Requires running Kimai with real time entries across multiple projects to verify visual filtering"
- test: "Verify filter dropdown appearance and positioning"
expected: "Dropdown appears to the right of the heatmap SVG, styled with Tabler form-select classes, responsive layout"
why_human: "Visual layout verification requires browser rendering"
---
# Phase 4: Heatmap Interaction Verification Report
**Phase Goal:** Users can click through to daily details and filter the heatmap by project or activity
**Verified:** 2026-04-08T15:37:00Z
**Status:** human_needed
**Re-verification:** No -- initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Clicking a day cell navigates to Kimai's timesheet view filtered to that specific date | VERIFIED | `heatmap.ts` lines 217-224: `onCellClick` constructs URL with `daterange` param and assigns to `window.location.href`. Tests in `interaction.test.ts` verify URL format. |
| 2 | A dropdown allows filtering the heatmap to show data for a single project or activity | PARTIAL | Project filtering fully implemented (dropdown, fetch with `?project=N`, re-render). Activity filtering intentionally omitted per 04-CONTEXT.md line 25. Roadmap SC and INTR-01 both specify "or activity". |
| 3 | Filtering updates the heatmap in place without a full page reload | VERIFIED | `heatmap.ts` lines 256-271: `select.addEventListener('change', ...)` triggers `fetch()` then `renderHeatmap()` on the existing `svgArea` div. Tests in `filter.test.ts` verify re-render. |
| 4 | JavaScript tests verify click navigation and tooltip interaction behavior | VERIFIED | `interaction.test.ts` has 7 tests (callback, URL format, project in URL, fallback). `filter.test.ts` has 9 tests (dropdown rendering, fetch params, re-render, empty state). All 52 tests pass. |
**Score:** 3/4 truths fully verified (1 partial)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `Service/HeatmapService.php` | getUserProjects() method | VERIFIED | Lines 54-68: queries distinct projects with IDENTITY join, returns `[{id, name}]` |
| `Widget/HeatmapWidget.php` | Injects service, getData returns projects | VERIFIED | Lines 11-12: constructor injection. Lines 40-47: getData returns projects array. No `final` keyword. |
| `Resources/views/widget/heatmap.html.twig` | data-projects and data-timesheet-url attributes | VERIFIED | Lines 9-10: both data attributes present with correct Twig expressions |
| `assets/src/heatmap.ts` | Click handler, filter dropdown, re-fetch | VERIFIED | Lines 198-201: click handler. Lines 236-276: filter dropdown. Lines 256-271: re-fetch on change. |
| `assets/src/types.ts` | ProjectOption interface | VERIFIED | Lines 22-25: `export interface ProjectOption { id: number; name: string; }` |
| `Resources/public/heatmap.css` | Click affordance, flex layout | VERIFIED | Lines 15-21: cursor:pointer, transition, pointer-events. Lines 38-58: flex wrapper layout. |
| `Resources/public/heatmap.js` | Rebuilt bundle | VERIFIED | 81,943 bytes, contains KimaiHeatmap IIFE |
| `assets/test/interaction.test.ts` | Click navigation tests | VERIFIED | 7 tests covering onCellClick callback, URL construction, project filter in URL |
| `assets/test/filter.test.ts` | Filter dropdown tests | VERIFIED | 9 tests covering rendering, options, fetch params, re-render, empty state |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `heatmap.ts` | Kimai timesheet | `window.location.href` with daterange | WIRED | Line 223: `window.location.href = url` with `encodeURIComponent(daterange)` |
| `heatmap.ts` | HeatmapController::data() | `fetch` with `?project=N` | WIRED | Line 259: `fetch(fetchUrl)` where `fetchUrl = baseUrl?project=val` |
| `HeatmapWidget.php` | `HeatmapService::getUserProjects()` | getData() calls service | WIRED | Line 45: `$this->service->getUserProjects($user)` |
| `interaction.test.ts` | `heatmap.ts` | imports renderHeatmap and init | WIRED | Line 3: `import { renderHeatmap, init } from '../src/heatmap'` |
| `filter.test.ts` | `heatmap.ts` | imports init | WIRED | Line 2: `import { init } from '../src/heatmap'` |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `heatmap.ts` (init) | projects | `data-projects` attr from Twig | Server-rendered from DB query via getUserProjects() | FLOWING |
| `heatmap.ts` (init) | HeatmapData | `fetch(baseUrl)` | API endpoint queries TimesheetRepository | FLOWING |
| `heatmap.ts` (filter) | HeatmapData | `fetch(baseUrl?project=N)` | API endpoint filters by projectId | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| All tests pass | `npx vitest run` | 52 passed (26 unique + symlink dupes), 0 failed | PASS |
| Bundle builds | `ls Resources/public/heatmap.js` | 81,943 bytes | PASS |
| No TODOs/FIXMEs in phase code | grep across modified files | No matches | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| HEAT-07 | 04-01 | Clicking a day cell navigates to Kimai timesheet view filtered to that date | SATISFIED | `heatmap.ts` onCellClick with window.location.href, daterange param |
| INTR-01 | 04-01 | Dropdown filter to view heatmap for a specific project or activity | PARTIAL | Project filtering implemented. Activity filtering omitted (intentional descope in 04-CONTEXT.md but not reflected in requirement text). |
| TEST-04 | 04-02 | JavaScript tests for tooltip and click interaction behavior | SATISFIED | interaction.test.ts (7 tests) + filter.test.ts (9 tests) all passing |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| None found | - | - | - | - |
### Human Verification Required
### 1. Click Navigation End-to-End
**Test:** Click a heatmap day cell in a running Kimai instance
**Expected:** Browser navigates to `/en/timesheet/?daterange=YYYY-MM-DD%20-%20YYYY-MM-DD` showing time entries for that date
**Why human:** Requires running Kimai to verify the timesheet page accepts and applies the daterange parameter correctly
### 2. Project Filter Visual Behavior
**Test:** Select a project from the filter dropdown with multiple projects having time data
**Expected:** Heatmap re-renders showing only that project's data, color scale recalculates, cells update
**Why human:** Requires running Kimai with real multi-project time data to verify visual filtering
### 3. Layout and Styling
**Test:** View the widget on the Kimai dashboard
**Expected:** Filter dropdown appears to the right of the heatmap, cells show pointer cursor on hover with opacity change, layout is clean
**Why human:** Visual layout verification requires browser rendering with Tabler CSS
### Gaps Summary
One partial gap identified: the roadmap success criteria and INTR-01 both specify filtering by "project **or activity**" but only project filtering was implemented. This was an intentional design decision documented in 04-CONTEXT.md ("Projects only -- no activity filter"), but it represents a deviation from the stated roadmap contract. No later phase addresses activity filtering.
The gap is flagged as partial rather than failed because:
- Project filtering is fully functional and tested
- The descope was a conscious decision, not an oversight
- The core interaction value (filtering the heatmap) is delivered for the primary dimension (projects)
All other success criteria are fully met with solid test coverage (16 new tests).
---
_Verified: 2026-04-08T15:37:00Z_
_Verifier: Claude (gsd-verifier)_

View file

@ -0,0 +1,230 @@
# Phase 1: Dev Environment — Plan
**Goal:** Developer can run `nix develop` and have a working Kimai instance with test data, ready for plugin development.
**Requirements:** DEV-01, DEV-02, DEV-03
**Research:** phase-1/RESEARCH.md
## Success Criteria
1. `nix develop` drops into a shell with PHP 8.2+, Composer, and Node available
2. A local Kimai instance starts and is accessible in the browser
3. The Kimai instance contains seeded time entry data spanning multiple months
4. A plugin directory is symlinked so code changes are reflected without reinstallation
## Waves
### Wave 1: Nix Flake + Infrastructure Scripts (autonomous)
#### Plan 01-01: Nix Flake and Dev Infrastructure
**Objective:** Create a reproducible dev environment with all dependencies and a one-command bootstrap.
**Task 1: Create Nix flake and devshell**
Create `flake.nix` at project root:
- Input: `nixpkgs` (use `github:NixOS/nixpkgs/nixpkgs-unstable`)
- DevShell packages:
- PHP 8.2 with extensions: `gd`, `intl`, `mbstring`, `pdo_mysql`, `xml`, `xsl`, `zip`, `tokenizer` — use `php82.buildEnv { extensions = { enabled, all }: enabled ++ (with all; [ xsl pdo_mysql ]); }`
- `php82Packages.composer`
- `nodejs_22`
- `mariadb` (11.4.x)
- `symfony-cli`
- `process-compose`
- Shell hook: print a message pointing to `dev/setup.sh` for first-time setup and `process-compose -f dev/process-compose.yaml up` for starting the stack
Create `.envrc` with `use flake` for direnv integration.
**Task 2: Create process-compose config**
Create `dev/process-compose.yaml`:
- `mariadb` process:
- Command: `mysqld --datadir=$PWD/dev/.mariadb-data --socket=$PWD/dev/.mariadb.sock --port=3307 --skip-grant-tables --skip-networking=0 --bind-address=127.0.0.1`
- Readiness probe: `mysqladmin --socket=$PWD/dev/.mariadb.sock ping` (initial delay 2s, period 1s)
- `kimai` process:
- Command: `symfony server:start --no-tls --dir=$PWD/dev/kimai --port=8010`
- Depends on: `mariadb` (condition: process_healthy)
**Task 3: Create setup script**
Create `dev/setup.sh` (executable):
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
KIMAI_DIR="$SCRIPT_DIR/kimai"
DATA_DIR="$SCRIPT_DIR/.mariadb-data"
# Step 1: Initialize MariaDB data directory
if [ ! -d "$DATA_DIR" ]; then
echo "==> Initializing MariaDB data directory..."
mysql_install_db --datadir="$DATA_DIR" --auth-root-authentication-method=normal
fi
# Step 2: Start MariaDB temporarily for setup
echo "==> Starting MariaDB for setup..."
mysqld --datadir="$DATA_DIR" --socket="$SCRIPT_DIR/.mariadb.sock" --port=3307 --skip-grant-tables --skip-networking=0 --bind-address=127.0.0.1 &
MARIADB_PID=$!
trap "kill $MARIADB_PID 2>/dev/null || true" EXIT
# Wait for MariaDB to be ready
for i in $(seq 1 30); do
if mysqladmin --socket="$SCRIPT_DIR/.mariadb.sock" ping 2>/dev/null; then
break
fi
sleep 1
done
# Create database
mysql --socket="$SCRIPT_DIR/.mariadb.sock" -e "CREATE DATABASE IF NOT EXISTS kimai;"
# Step 3: Clone Kimai
if [ ! -d "$KIMAI_DIR" ]; then
echo "==> Cloning Kimai 2.52.0..."
git clone -b 2.52.0 --depth 1 https://github.com/kimai/kimai.git "$KIMAI_DIR"
fi
# Step 4: Configure Kimai
cat > "$KIMAI_DIR/.env.local" <<EOF
DATABASE_URL=mysql://root@127.0.0.1:3307/kimai?charset=utf8mb4&serverVersion=11.4.8-MariaDB
APP_SECRET=$(openssl rand -hex 16)
APP_ENV=dev
MAILER_URL=null://null
EOF
# Step 5: Install Composer dependencies
echo "==> Installing Composer dependencies..."
cd "$KIMAI_DIR"
composer install --no-interaction
# Step 6: Install Kimai (creates schema, runs migrations)
echo "==> Installing Kimai..."
bin/console kimai:install -n
# Step 7: Load test fixtures
echo "==> Loading test fixtures..."
bin/console kimai:reset:dev -n
# Step 8: Symlink plugin
echo "==> Symlinking plugin..."
mkdir -p var/plugins
ln -sfn "$PROJECT_DIR" var/plugins/KimaiHeatmapBundle
# Step 9: Clear cache
bin/console cache:clear
echo ""
echo "==> Setup complete!"
echo " Run: process-compose -f dev/process-compose.yaml up"
echo " Then open: http://127.0.0.1:8010"
echo " Login: susan_super / password"
```
**Task 4: Create .gitignore**
Create/update `.gitignore`:
```
dev/kimai/
dev/.mariadb-data/
dev/.mariadb.sock
dev/.mariadb.sock.lock
node_modules/
vendor/
.direnv/
```
**Commit:** `feat: add nix flake, process-compose, and setup script for dev environment`
**Verification:**
- `nix develop --command bash -c "php --version && composer --version && node --version"` succeeds
- `nix develop --command bash -c "which mariadb && which symfony && which process-compose"` succeeds
---
### Wave 2: Kimai Instance + Plugin Scaffold (checkpoint required)
#### Plan 01-02: Bootstrap Kimai and Create Plugin Scaffold
**Objective:** Get Kimai running with test data and the plugin recognized.
**Task 1: Create minimal plugin scaffold**
Create these files at project root (the project root IS the plugin bundle):
`KimaiHeatmapBundle.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle;
use App\Plugin\PluginInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class KimaiHeatmapBundle extends Bundle implements PluginInterface
{
}
```
`composer.json`:
```json
{
"name": "kimai-plugin/heatmap-bundle",
"type": "kimai-plugin",
"description": "GitHub-style activity heatmap dashboard widget for Kimai",
"license": "MIT",
"autoload": {
"psr-4": {
"KimaiPlugin\\KimaiHeatmapBundle\\": ""
}
},
"extra": {
"kimai": {
"require": 25200,
"name": "Activity Heatmap"
}
}
}
```
**Commit:** `feat: add minimal plugin scaffold (bundle class + composer.json)`
**Task 2: Run setup and verify (CHECKPOINT — requires manual browser verification)**
1. Enter devshell: `nix develop`
2. Run setup: `bash dev/setup.sh`
3. Start stack: `process-compose -f dev/process-compose.yaml up`
4. Open browser: `http://127.0.0.1:8010`
5. Login: `susan_super` / `password`
**Verification checklist:**
- [ ] Kimai dashboard loads in browser
- [ ] `cd dev/kimai && bin/console kimai:plugins` lists "Activity Heatmap"
- [ ] `cd dev/kimai && bin/console doctrine:query:sql "SELECT COUNT(*) FROM kimai2_timesheet"` returns > 0 rows
- [ ] Plugin appears in Kimai admin > Plugins page
## Requirement Coverage
| Requirement | Plan | Verified By |
|-------------|------|-------------|
| DEV-01 | 01-01 | `nix develop` provides PHP 8.2+, Composer, Node |
| DEV-02 | 01-02 | `kimai:reset:dev` loads fixtures; SQL count check |
| DEV-03 | 01-02 | `kimai:plugins` lists the plugin; plugin visible in admin |
## Risks
| Risk | Mitigation |
|------|------------|
| MariaDB local datadir doesn't work | Fall back to `--socket` only or use `mysql_install_db --user=$(whoami)` |
| PHP extension missing in Nix | Add explicit extensions to `php.buildEnv`; iterate based on `composer install` errors |
| Kimai 2.52.0 incompatible with MariaDB 11.4 | Try `mariadb_1011` (10.11.x) from nixpkgs as fallback |
| `kimai:reset:dev` fixture count too low | Acceptable for dev — supplement with API calls later if needed |
## Notes
- Timebox this phase to 1 day. If Nix+MariaDB setup takes longer, consider Docker fallback.
- The plugin scaffold is intentionally minimal — just enough for Kimai to recognize it. Real functionality comes in Phase 2.
- `APP_ENV=dev` enables Symfony's auto-recompilation, so cache:clear is only needed after initial plugin symlink.
---
*Plan created: 2026-04-08*

View file

@ -0,0 +1,486 @@
# Phase 2: Plugin Scaffold + Data Layer — Plan
**Goal:** Plugin shows a widget on the dashboard and serves aggregated daily time data via API.
**Requirements:** PLUG-01 (done), PLUG-02, PLUG-03, TEST-01, TEST-02
**Research:** phase-2/02-RESEARCH.md
## Success Criteria
1. Kimai discovers the plugin and lists it in the plugin admin page (DONE — Phase 1)
2. A widget placeholder appears on the Kimai dashboard
3. The API endpoint returns JSON with per-day aggregated hours and entry counts, correctly grouped by the user's timezone
4. PHPUnit tests pass for the data aggregation service and API endpoint
## Key Decisions from Research
- Extend `AbstractWidget` (not `AbstractWidgetType`) — same pattern as `DailyWorkingTimeChart`
- Query `t.date` (the `date_tz` column) for timezone-correct day grouping — no manual conversion needed
- Simple `JsonResponse` controller, NOT FOS Rest Bundle — cleaner for plugin
- Unit tests with mocked repository for Phase 2 — integration tests deferred
- `DashboardSubscriber` needed for widget to appear on default dashboard
## Waves
### Wave 1: Data Layer + Tests (autonomous)
#### Plan 02-01: HeatmapService, PHPUnit Tests, and API Endpoint
**Objective:** Build the data aggregation service with tests, then the API controller.
**Task 1: Update services.yaml for autowiring**
Update `Resources/config/services.yaml` to enable autowiring and autoconfigure for the plugin namespace:
```yaml
services:
_defaults:
autowire: true
autoconfigure: true
KimaiPlugin\KimaiHeatmapBundle\:
resource: '../../{Controller,Service,Widget,EventSubscriber}/'
KimaiPlugin\KimaiHeatmapBundle\KimaiHeatmapBundle:
tags: ['App\Plugin\PluginInterface']
```
**Task 2: Create HeatmapService**
Create `Service/HeatmapService.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
final class HeatmapService
{
public function __construct(private readonly TimesheetRepository $repository)
{
}
/**
* @return array<int, array{date: string, hours: float, count: int}>
*/
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COUNT(t.id) as count')
->addSelect('DATE(t.date) as day')
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $user)
->groupBy('day')
->orderBy('day', 'ASC')
;
if ($projectId !== null) {
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
->setParameter('project', $projectId);
}
$results = $qb->getQuery()->getResult();
return array_map(function (array $row) {
return [
'date' => $row['day'],
'hours' => round((int) $row['duration'] / 3600, 2),
'count' => (int) $row['count'],
];
}, $results);
}
}
```
**Task 3: Create PHPUnit config and tests**
Create `Tests/phpunit.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="bootstrap.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>.</directory>
</testsuite>
</testsuites>
</phpunit>
```
Create `Tests/bootstrap.php`:
```php
<?php
// When symlinked into Kimai's var/plugins/, the vendor autoload is two levels up
$autoloadPaths = [
__DIR__ . '/../../dev/kimai/vendor/autoload.php', // Running from plugin root
__DIR__ . '/../../../../vendor/autoload.php', // Running from inside var/plugins/
];
foreach ($autoloadPaths as $path) {
if (file_exists($path)) {
require_once $path;
return;
}
}
throw new RuntimeException('Cannot find Kimai vendor/autoload.php. Run composer install in dev/kimai/ first.');
```
Create `Tests/Service/HeatmapServiceTest.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Tests\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use PHPUnit\Framework\TestCase;
class HeatmapServiceTest extends TestCase
{
public function testGetDailyAggregationReturnsFormattedResults(): void
{
$mockResults = [
['day' => '2026-04-01', 'duration' => 7200, 'count' => 3],
['day' => '2026-04-02', 'duration' => 3600, 'count' => 1],
];
$service = $this->createServiceWithResults($mockResults);
$result = $service->getDailyAggregation(
$this->createMock(User::class),
new \DateTimeImmutable('2026-04-01'),
new \DateTimeImmutable('2026-04-30')
);
$this->assertCount(2, $result);
$this->assertEquals('2026-04-01', $result[0]['date']);
$this->assertEquals(2.0, $result[0]['hours']);
$this->assertEquals(3, $result[0]['count']);
$this->assertEquals('2026-04-02', $result[1]['date']);
$this->assertEquals(1.0, $result[1]['hours']);
$this->assertEquals(1, $result[1]['count']);
}
public function testGetDailyAggregationReturnsEmptyForNoData(): void
{
$service = $this->createServiceWithResults([]);
$result = $service->getDailyAggregation(
$this->createMock(User::class),
new \DateTimeImmutable('2026-04-01'),
new \DateTimeImmutable('2026-04-30')
);
$this->assertCount(0, $result);
}
public function testHoursRoundedToTwoDecimals(): void
{
$mockResults = [
['day' => '2026-04-01', 'duration' => 5431, 'count' => 2],
];
$service = $this->createServiceWithResults($mockResults);
$result = $service->getDailyAggregation(
$this->createMock(User::class),
new \DateTimeImmutable('2026-04-01'),
new \DateTimeImmutable('2026-04-30')
);
$this->assertEquals(1.51, $result[0]['hours']);
}
private function createServiceWithResults(array $results): HeatmapService
{
$query = $this->createMock(AbstractQuery::class);
$query->method('getResult')->willReturn($results);
$qb = $this->createMock(QueryBuilder::class);
$qb->method('select')->willReturnSelf();
$qb->method('addSelect')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('setParameter')->willReturnSelf();
$qb->method('groupBy')->willReturnSelf();
$qb->method('orderBy')->willReturnSelf();
$qb->method('expr')->willReturn(new Expr());
$qb->method('getQuery')->willReturn($query);
$repo = $this->createMock(TimesheetRepository::class);
$repo->method('createQueryBuilder')->willReturn($qb);
return new HeatmapService($repo);
}
}
```
**Task 4: Create API controller**
Create `Controller/HeatmapController.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Controller;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/heatmap')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
class HeatmapController extends AbstractController
{
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function data(Request $request, HeatmapService $service): JsonResponse
{
$user = $this->getUser();
$end = new \DateTimeImmutable('today');
$begin = $end->modify('-1 year');
$projectId = $request->query->getInt('project') ?: null;
$days = $service->getDailyAggregation($user, $begin, $end, $projectId);
return new JsonResponse([
'days' => $days,
'range' => [
'begin' => $begin->format('Y-m-d'),
'end' => $end->format('Y-m-d'),
],
]);
}
}
```
Create `Resources/config/routes.yaml`:
```yaml
heatmap_controllers:
resource: '../../Controller/'
type: attribute
```
**Task 5: Create controller test**
Create `Tests/Controller/HeatmapControllerTest.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Tests\Controller;
use App\Entity\User;
use KimaiPlugin\KimaiHeatmapBundle\Controller\HeatmapController;
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class HeatmapControllerTest extends TestCase
{
public function testDataReturnsJsonWithDaysAndRange(): void
{
$mockDays = [
['date' => '2026-04-01', 'hours' => 2.0, 'count' => 3],
];
$service = $this->createMock(HeatmapService::class);
$service->method('getDailyAggregation')->willReturn($mockDays);
$controller = new HeatmapController();
// Mock user via reflection (AbstractController::getUser is protected)
$user = $this->createMock(User::class);
$container = $this->createMock(\Symfony\Component\DependencyInjection\ContainerInterface::class);
$tokenStorage = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface::class);
$token = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\TokenInterface::class);
$token->method('getUser')->willReturn($user);
$tokenStorage->method('getToken')->willReturn($token);
$container->method('has')->willReturn(true);
$container->method('get')->willReturn($tokenStorage);
$controller->setContainer($container);
$response = $controller->data(new Request(), $service);
$this->assertInstanceOf(JsonResponse::class, $response);
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('days', $data);
$this->assertArrayHasKey('range', $data);
$this->assertCount(1, $data['days']);
$this->assertArrayHasKey('begin', $data['range']);
$this->assertArrayHasKey('end', $data['range']);
}
}
```
**Commit:** `feat: add HeatmapService, API controller, and PHPUnit tests`
**Verification:**
- `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` — all tests pass
---
### Wave 2: Dashboard Widget (checkpoint for visual verification)
#### Plan 02-02: HeatmapWidget + Dashboard Integration
**Objective:** Widget placeholder appears on the Kimai dashboard.
**Task 1: Create HeatmapWidget**
Create `Widget/HeatmapWidget.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\Widget;
use App\Widget\Type\AbstractWidget;
use App\Widget\WidgetInterface;
final class HeatmapWidget extends AbstractWidget
{
public function getId(): string
{
return 'HeatmapWidget';
}
public function getTitle(): string
{
return 'Activity Heatmap';
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_FULL;
}
public function getHeight(): int
{
return WidgetInterface::HEIGHT_LARGE;
}
public function getPermissions(): array
{
return ['view_own_timesheet'];
}
public function getData(array $options = []): mixed
{
return null;
}
public function getTemplateName(): string
{
return '@KimaiHeatmapBundle/widget/heatmap.html.twig';
}
}
```
**Task 2: Create widget template**
Create `Resources/views/widget/heatmap.html.twig`:
```twig
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% block box_title %}
{{ title }}
{% endblock %}
{% block box_body %}
<div id="heatmap-container" data-url="{{ path('heatmap_data') }}" style="min-height: 150px; padding: 1rem;">
<p style="color: var(--bs-secondary);">Heatmap visualization coming in Phase 3</p>
</div>
{% endblock %}
{% endembed %}
```
**Task 3: Create DashboardSubscriber**
Create `EventSubscriber/DashboardSubscriber.php`:
```php
<?php
namespace KimaiPlugin\KimaiHeatmapBundle\EventSubscriber;
use App\Event\DashboardEvent;
use App\Widget\WidgetService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class DashboardSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly WidgetService $widgetService)
{
}
public static function getSubscribedEvents(): array
{
return [
DashboardEvent::class => ['onDashboard', 100],
];
}
public function onDashboard(DashboardEvent $event): void
{
$widget = $this->widgetService->getWidget('HeatmapWidget');
if ($widget !== null) {
$event->addWidget($widget);
}
}
}
```
**Commit:** `feat: add dashboard widget with placeholder template`
**Task 4: Verify (CHECKPOINT — requires manual verification)**
1. Clear Kimai cache: `cd dev/kimai && bin/console cache:clear`
2. Start dev stack: `process-compose -f dev/process-compose.yaml -p 0 up`
3. Open browser: `http://127.0.0.1:8010`
4. Login: `susan_super` / `password`
**Verification checklist:**
- [ ] "Activity Heatmap" widget appears on the dashboard
- [ ] Widget shows placeholder text "Heatmap visualization coming in Phase 3"
- [ ] Visiting `/heatmap/data` returns JSON with `days` array and `range` object
- [ ] JSON `days` entries have `date`, `hours`, `count` fields
- [ ] PHPUnit tests pass: `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml`
## Requirement Coverage
| Requirement | Plan | Verified By |
|-------------|------|-------------|
| PLUG-01 | Phase 1 | Already done |
| PLUG-02 | 02-02 | Widget visible on dashboard |
| PLUG-03 | 02-01 | API returns JSON; PHPUnit test |
| TEST-01 | 02-01 | HeatmapServiceTest passes |
| TEST-02 | 02-01 | HeatmapControllerTest passes |
## Risks
| Risk | Mitigation |
|------|------------|
| `@theme/embeds/card.html.twig` doesn't exist | Fall back to plain `<div>` wrapper |
| `DATE()` DQL function fails on MariaDB | Phase 1 uses MariaDB — should work natively |
| Widget not appearing despite registration | Check `WidgetService` debug, ensure autoconfigure is true |
| Test bootstrap can't find Kimai autoloader | Multiple path fallbacks in bootstrap.php |
---
*Plan created: 2026-04-08*

View file

@ -0,0 +1,85 @@
# Phase 3 Research: Core Heatmap Rendering
**Researched:** 2026-04-08
## Kimai Widget Asset Serving
### How existing widgets load JS
- Kimai widgets use **inline `<script>` tags** in their Twig templates
- JS fires on `kimai.initialized` event (or immediately if `kimai_context.javascriptRequest` is true)
- Chart.js widgets (DailyWorkingTimeChart, YearChart) use global `Chart` object loaded via Encore
- No widget uses `<script type="module">` — all use plain `<script type="text/javascript">`
### Plugin asset path
- Plugin `Resources/public/``public/bundles/kimaiheatmap/` via `bin/console assets:install --symlink`
- Accessible in Twig: `asset('heatmap.js', 'KimaiHeatmap')` — but lowercase bundle name in path
- Alternative: inline the bundled JS directly in the template (simpler, avoids asset path issues)
### Recommended approach for d3 heatmap
**Bundle d3 modules with esbuild into a single IIFE file**, then either:
1. Serve from `Resources/public/heatmap.js` via asset symlink + `<script>` tag
2. Or inline the compiled JS in the template
Option 1 is cleaner. The esbuild output should be an IIFE (not ESM) since Kimai templates use plain script tags.
### kimai_context.javascriptRequest
Boolean flag set by Kimai. When the dashboard reloads via AJAX (e.g., GridStack widget repositioning), this is `true`. The pattern:
```twig
{% if kimai_context.javascriptRequest %}
renderHeatmap();
{% else %}
document.addEventListener('kimai.initialized', renderHeatmap);
{% endif %}
```
### Widget template variables
When `render_widget(widget)` is called, the template receives:
- `widget` — the widget instance
- `title` — from `widget.getTitle()`
- `data` — from `widget.getData()`
- `options` — from `widget.getOptions()`
### card.html.twig embed blocks
- `box_title` — card header text
- `box_body` — main content area
- `box_footer` — optional footer
- `box_tools` — header action buttons
- `box_header` — full header override
### Dashboard initialization events
- `kimai.initialized` — main app ready
- `dashboard.initialized` — GridStack layout ready (only in grid.html.twig)
## d3 Calendar Heatmap Pattern
### Grid layout
- 53 columns (weeks) × 7 rows (days, Mon-Sun)
- Cell size: ~12-14px square with 2px gap
- Total width: ~800px, fits full-width widget
### Color scale
Use `d3.scaleSequential` with interpolator mapped to CSS variables:
- 0 hours: `var(--heatmap-empty)` (light gray / dark theme equivalent)
- Low: light green
- High: dark green
- Use Kimai's `--bs-success` as base, generate lighter/darker variants
### Tooltip
HTML div positioned absolutely near the hovered cell. Show date, hours, entry count.
### Month labels
Detect when week crosses month boundary. Place label at first week of each month.
### Day-of-week labels
Mon, Wed, Fri on Y-axis (standard GitHub heatmap pattern — skip Tue/Thu/Sat/Sun for space).
## Testing Strategy
### Vitest + jsdom
- d3-selection works with jsdom's DOM
- Test SVG output: correct number of `<rect>` elements, color attributes, tooltip content
- Snapshot test for overall structure
- Unit test color scale mapping
### Test data
Generate mock API response matching `{ days: [{date, hours, count}], range: {begin, end} }` format.

View file

@ -0,0 +1,338 @@
# Phase 3: Core Heatmap Rendering — Plan
**Goal:** The dashboard widget renders a fully styled d3.js calendar heatmap from live Kimai data
**Requirements:** HEAT-01, HEAT-02, HEAT-03, HEAT-04, HEAT-05, HEAT-06, HEAT-08, TEST-03
**Research:** phase-3/03-RESEARCH.md
## Success Criteria
1. The widget displays a weeks-by-days calendar grid with cells colored by hours tracked (darker = more hours)
2. Hovering any cell shows a tooltip with date, hours, and entry count
3. Day-of-week labels appear on the Y-axis and month boundary labels along the top
4. Days with no tracked time render with a distinct "no data" color
5. Colors use Kimai's theme CSS variables (works with both light and dark themes)
## Key Decisions from Research
- **IIFE bundle** — esbuild compiles d3 modules into a single IIFE file (not ESM), matching Kimai's plain `<script>` pattern
- **Asset symlink**`Resources/public/heatmap.js` served via `bin/console assets:install --symlink` at `public/bundles/kimaiheatmap/`
- **kimai.initialized event** — JS fires on this event, with `kimai_context.javascriptRequest` fallback for AJAX reloads
- **Tabler CSS variables** — Use `--tblr-green` scale (`--tblr-green-lt`, `--tblr-green`, `--tblr-green-darken`) + `--tblr-bg-surface-secondary` for empty cells. Auto-adapts to dark mode.
- **Inline CSS** — Ship `Resources/public/heatmap.css` alongside the JS for heatmap-specific styles (tooltip, grid layout)
- **Data via fetch** — Template passes API URL as `data-url` attribute, JS fetches from `/heatmap/data`
## Waves
### Wave 1: JS Toolchain Setup (autonomous)
#### Plan 03-01: npm, esbuild, TypeScript, Vitest configuration
**Objective:** Set up the JavaScript build pipeline so d3 code can be authored in TypeScript, bundled, and tested.
**Task 1: Initialize package.json and install dependencies**
Create `package.json` at plugin root:
```json
{
"name": "kimai-heatmap-bundle",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild assets/src/heatmap.ts --bundle --outfile=Resources/public/heatmap.js --format=iife --global-name=KimaiHeatmap --minify",
"build:dev": "esbuild assets/src/heatmap.ts --bundle --outfile=Resources/public/heatmap.js --format=iife --global-name=KimaiHeatmap --sourcemap",
"test": "vitest run",
"test:watch": "vitest"
}
}
```
Then install:
```bash
npm install d3-scale d3-selection d3-time d3-time-format d3-scale-chromatic d3-array
npm install -D typescript esbuild vitest jsdom @vitest/coverage-v8 \
@types/d3-scale @types/d3-selection @types/d3-time @types/d3-time-format @types/d3-scale-chromatic @types/d3-array
```
**Task 2: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["assets/src/**/*.ts", "assets/test/**/*.ts"]
}
```
**Task 3: Create vitest.config.ts**
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
```
**Task 4: Create placeholder source and verify toolchain**
Create `assets/src/heatmap.ts`:
```typescript
export function init(container: HTMLElement): void {
console.log('Heatmap init', container);
}
```
Create `assets/test/heatmap.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
describe('heatmap', () => {
it('placeholder', () => {
expect(true).toBe(true);
});
});
```
**Verification:**
- `npm run build` — produces `Resources/public/heatmap.js`
- `npm test` — 1 test passes
- `npx tsc --noEmit` — no type errors
**Commit:** `feat: add JS toolchain — esbuild, TypeScript, Vitest`
---
### Wave 2: Core Heatmap Rendering + Tests (autonomous)
#### Plan 03-02: d3 calendar heatmap with color scale, labels, tooltips
**Objective:** Implement the full d3.js heatmap rendering in TypeScript with Vitest tests.
**Task 1: Define types**
Create `assets/src/types.ts`:
```typescript
export interface DayEntry {
date: string; // "YYYY-MM-DD"
hours: number;
count: number;
}
export interface HeatmapData {
days: DayEntry[];
range: {
begin: string;
end: string;
};
}
export interface HeatmapConfig {
cellSize: number;
cellGap: number;
marginTop: number;
marginLeft: number;
marginBottom: number;
}
```
**Task 2: Implement heatmap rendering**
Create `assets/src/heatmap.ts` (replace placeholder):
Core logic:
1. **Fetch data** from the URL in `data-url` attribute
2. **Build date map** — index `DayEntry[]` by date string for O(1) lookup
3. **Generate grid** — iterate from `range.begin` to `range.end`:
- X position: week index (column)
- Y position: day-of-week (row, 0=Sun through 6=Sat, or Monday-start)
4. **Color scale**`d3.scaleSequential` mapping 0→maxHours to a 5-step green palette using CSS custom properties:
- No data: CSS class `heatmap-empty` → uses `var(--tblr-bg-surface-secondary)`
- Low→High: 4-step green scale from `var(--tblr-green-lt)` through `var(--tblr-green-darken)`
5. **SVG rendering**`d3.select(container).append('svg')`, then `selectAll('rect').data(days).join('rect')`
6. **Day labels** — Mon, Wed, Fri text elements on left axis
7. **Month labels** — text elements at top, positioned at first week of each month
8. **Tooltip** — on `mouseenter`, show a positioned `<div>` with date/hours/count; hide on `mouseleave`
Key implementation details:
- Use `d3.timeMonday` for week start (ISO standard, matches Kimai's European locale)
- Cell size 13px, gap 2px
- SVG viewBox for responsive sizing within the widget
- Expose `init(container: HTMLElement): void` as the entry point
- Handle empty data gracefully (show "No data" message)
**Task 3: Create CSS**
Create `assets/src/heatmap.css`:
```css
.heatmap-tooltip {
position: absolute;
padding: 6px 10px;
background: var(--tblr-bg-surface);
border: 1px solid var(--tblr-border-color);
border-radius: 4px;
font-size: 0.8125rem;
color: var(--tblr-body-color);
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.heatmap-cell {
rx: 2;
ry: 2;
}
.heatmap-empty {
fill: var(--tblr-bg-surface-secondary);
}
.heatmap-label {
fill: var(--tblr-body-color);
font-size: 10px;
font-family: var(--tblr-font-sans-serif);
}
```
Copy this file to `Resources/public/heatmap.css` in the build step (or add to esbuild).
**Task 4: Write Vitest tests**
Create `assets/test/heatmap.test.ts`:
Tests to implement:
1. **Grid structure** — renders correct number of `<rect>` elements for a 1-year range (~365)
2. **Color mapping** — cells with 0 hours have `heatmap-empty` class, cells with data have fill color
3. **Day labels** — SVG contains Mon, Wed, Fri text elements
4. **Month labels** — SVG contains month abbreviation text elements (Jan, Feb, ...)
5. **Tooltip** — mouseenter on a cell creates tooltip div with correct content; mouseleave removes it
6. **Empty data** — handles empty `days` array without errors
7. **Date range** — only renders cells within the begin/end range
Test approach:
- Create a `<div>` in jsdom
- Call `renderHeatmap(container, mockData)` (separate from `init` which does the fetch)
- Assert on the resulting DOM: `container.querySelectorAll('rect')`, tooltip content, text labels
**Verification:**
- `npm test` — all tests pass
- `npm run build` — produces minified `Resources/public/heatmap.js`
- `npx tsc --noEmit` — no type errors
**Commit:** `feat: d3 calendar heatmap with color scale, labels, and tooltips`
---
### Wave 3: Template Integration (checkpoint for visual verification)
#### Plan 03-03: Twig template update + asset serving
**Objective:** Wire the built JS/CSS into the Kimai widget template so it renders on the dashboard.
**Task 1: Update widget template**
Replace `Resources/views/widget/heatmap.html.twig`:
```twig
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% block box_title %}
{{ title }}
{% endblock %}
{% block box_body %}
<link rel="stylesheet" href="{{ asset('bundles/kimaiheatmap/heatmap.css') }}">
<div id="heatmap-container"
data-url="{{ path('heatmap_data') }}"
style="min-height: 150px; overflow-x: auto;">
</div>
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>
<script type="text/javascript">
var initHeatmap = function() {
KimaiHeatmap.init(document.getElementById('heatmap-container'));
};
{% if kimai_context is defined and kimai_context.javascriptRequest %}
initHeatmap();
{% else %}
document.addEventListener('kimai.initialized', initHeatmap);
{% endif %}
</script>
{% endblock %}
{% endembed %}
```
**Task 2: Install assets**
```bash
cd dev/kimai && bin/console assets:install public --symlink
```
This creates `dev/kimai/public/bundles/kimaiheatmap/` → plugin's `Resources/public/`.
**Task 3: Add build step to dev workflow**
Add a note in the README or dev setup that `npm run build` must be run after JS changes. Optionally add an esbuild watch command to process-compose.
**Task 4: Update .gitignore**
Add to `.gitignore`:
```
node_modules/
```
Ensure `Resources/public/heatmap.js` and `Resources/public/heatmap.css` are NOT gitignored — they're the built artifacts that ship with the plugin.
**Commit:** `feat: wire heatmap JS/CSS into dashboard widget template`
**Task 5: Verify (CHECKPOINT — requires manual verification)**
1. Build JS: `npm run build`
2. Install assets: `cd dev/kimai && bin/console assets:install public --symlink`
3. Clear cache: `cd dev/kimai && bin/console cache:clear`
4. Start dev stack: `process-compose -f dev/process-compose.yaml -p 0 up`
5. Open browser: `http://127.0.0.1:8010`
6. Login: `susan_super` / `password`
**Verification checklist:**
- [ ] Heatmap widget renders a calendar grid with colored cells
- [ ] Hovering a cell shows tooltip with date, hours, count
- [ ] Day-of-week labels (Mon, Wed, Fri) visible on left
- [ ] Month labels visible along top
- [ ] Empty days show distinct "no data" color
- [ ] Switch to dark theme — colors adapt correctly
- [ ] PHPUnit tests still pass: `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml`
- [ ] Vitest tests pass: `npm test`
## Requirement Coverage
| Requirement | Plan | Verified By |
|-------------|------|-------------|
| HEAT-01 | 03-02 | Calendar grid renders with d3; Vitest grid structure test |
| HEAT-02 | 03-02 | Color scale maps hours to intensity; Vitest color mapping test |
| HEAT-03 | 03-02 | Tooltip on hover; Vitest tooltip test |
| HEAT-04 | 03-02 | Day labels in SVG; Vitest day labels test |
| HEAT-05 | 03-02 | Month labels in SVG; Vitest month labels test |
| HEAT-06 | 03-02 | Empty cells have distinct class; Vitest empty cell test |
| HEAT-08 | 03-02, 03-03 | CSS uses `--tblr-*` variables; manual dark/light theme check |
| TEST-03 | 03-02 | Vitest test suite for d3 rendering |
## Risks
| Risk | Mitigation |
|------|------------|
| `assets:install --symlink` doesn't follow plugin symlink chain | Fall back to `assets:install` (copy mode) or manual symlink |
| d3 modules don't work in IIFE bundle | esbuild handles ESM→IIFE natively; verified in Task 4 of 03-01 |
| jsdom lacks SVG support for Vitest | d3-selection works with jsdom; test DOM attributes not visual rendering |
| `kimai_context` not available in widget template | Guard with `is defined` check; fall back to event listener only |
| Widget too wide/narrow in different dashboard layouts | Use SVG viewBox for responsive scaling; `overflow-x: auto` as safety net |
---
*Plan created: 2026-04-08*

View file

@ -0,0 +1,36 @@
# Phase 4 Research: Heatmap Interaction
**Researched:** 2026-04-08
## Kimai Timesheet Navigation
### Route
- Route name: `timesheet`
- URL: `/timesheet`
- Date filtering uses toolbar form with `dateRange` parameter
- Query param format for date filtering needs experimentation, but likely: `/timesheet?dateRange=2026-04-08+-+2026-04-08` or similar DateRange format
### Click-through approach
Navigate to `/timesheet` with the clicked date. The Twig template should pass the timesheet base URL via a `data-timesheet-url` attribute. The JS builds the full URL with date params.
## Project Filtering
### API endpoint
- `GET /api/projects?visible=1&orderBy=name` returns JSON array of projects
- Each project has `id`, `name`, `customer` fields
- This is a standard Kimai API endpoint with auth
### Current HeatmapController
Already supports `?project=<id>` query param for filtering aggregation data. No backend changes needed.
### UI approach
- Render a `<select>` dropdown above the heatmap
- Fetch project list from `/api/projects` on init
- On change, re-fetch heatmap data with `?project=<id>` and re-render
- "All projects" as default option (no filter)
## Implementation Notes
- Click navigation and project filtering are independent features
- Both are JS-only changes + template updates (no new PHP code needed)
- Tests: mock fetch responses, verify click handlers set `window.location`, verify dropdown triggers re-render

View file

@ -0,0 +1,142 @@
# Phase 4: Heatmap Interaction — Plan
**Goal:** Users can click through to daily details and filter the heatmap by project or activity
**Requirements:** HEAT-07, INTR-01, TEST-04
**Research:** phase-4/04-RESEARCH.md
## Success Criteria
1. Clicking a day cell navigates to Kimai's timesheet view filtered to that specific date
2. A dropdown allows filtering the heatmap to show data for a single project or activity
3. Filtering updates the heatmap in place without a full page reload
4. JavaScript tests verify click navigation and tooltip interaction behavior
## Key Decisions from Research
- **Click navigation**`window.location.href` to `/timesheet` with date param. Timesheet URL passed via `data-timesheet-url` template attribute.
- **Project dropdown** — Fetch `/api/projects` client-side, render `<select>` above heatmap. On change, re-fetch `/heatmap/data?project=<id>` and re-render.
- **No backend changes** — HeatmapController already supports `?project=` filtering. Project API is built into Kimai.
- **Activity filtering deferred** — INTR-01 says "project or activity". Start with project filter; activity can be added as a follow-up if needed since the API pattern is identical.
## Waves
### Wave 1: Click-through + Project Filter (autonomous)
#### Plan 04-01: Day cell click navigation + project filter dropdown
**Objective:** Add click-to-navigate on day cells and a project filter dropdown that re-renders the heatmap.
**Task 1: Update types**
Add to `assets/src/types.ts`:
```typescript
export interface Project {
id: number;
name: string;
}
export interface HeatmapOptions {
dataUrl: string;
timesheetUrl: string;
}
```
**Task 2: Add click handler to heatmap cells**
In `assets/src/heatmap.ts`:
- Add a `timesheetUrl` parameter to `renderHeatmap` (passed via options or config)
- On each `<rect>` cell, add a `click` event handler:
- Navigate to `${timesheetUrl}?dateRange=${dateStr}+-+${dateStr}`
- Only navigate if the cell has data (skip empty days)
- Add `cursor: pointer` style to cells with data
- Add `data-date` attribute to each rect for testability
**Task 3: Add project filter dropdown**
In `assets/src/heatmap.ts`, update the `init` function:
1. Before fetching heatmap data, fetch `/api/projects?visible=1&orderBy=name`
2. Create a `<select>` element with:
- Default option: "All projects"
- One `<option>` per project with `value=project.id`
3. Insert the select above the heatmap container (or in a header area)
4. On `change`, re-fetch heatmap data with `?project=<selectedId>` and call `renderHeatmap` again
5. Style the select to match Kimai's form controls (use `form-select` Bootstrap class)
**Task 4: Update widget template**
Update `Resources/views/widget/heatmap.html.twig`:
- Add `data-timesheet-url="{{ path('timesheet') }}"` to the container div
- The `data-url` attribute already exists for the heatmap API
**Task 5: Build and verify**
- `npm run build` — produces updated bundle
- `npm test` — existing tests still pass
- `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` — PHP tests pass
**Commit:** `feat: add day click navigation and project filter dropdown`
---
### Wave 2: Interaction Tests (autonomous)
#### Plan 04-02: Vitest tests for click navigation and filter behavior
**Objective:** Add JavaScript tests covering the new interaction features.
**Task 1: Write click navigation tests**
Add to `assets/test/heatmap.test.ts`:
1. **Click on cell with data** — simulate click on a cell, verify `window.location.href` was set to expected timesheet URL with date
2. **Click on empty cell** — simulate click, verify no navigation occurred
3. **Cell cursor style** — cells with data have `cursor: pointer`, empty cells don't
Test approach:
- Mock `window.location` (use `Object.defineProperty` or vitest's `vi.stubGlobal`)
- Pass `timesheetUrl` option to `renderHeatmap`
- Use `container.querySelector('[data-date="2026-04-01"]')` to find specific cells
- Dispatch `click` event
**Task 2: Write project filter tests**
Add tests for the filter dropdown behavior:
1. **Dropdown renders with projects** — after init with mocked fetch, a `<select>` element exists with project options
2. **Filter triggers re-fetch** — changing dropdown value triggers a new fetch with `?project=<id>` param
3. **Heatmap re-renders** — after filter change, new data is rendered (different rect count or content)
4. **"All projects" resets filter** — selecting default option fetches without project param
Test approach:
- Mock `fetch` to return project list and heatmap data
- Call `init(container)` and await fetch completion
- Programmatically change select value and dispatch `change` event
- Assert fetch was called with correct URL params
**Verification:**
- `npm test` — all tests pass (existing + new)
- `npm run build` — bundle builds successfully
**Commit:** `test: add click navigation and project filter tests`
---
## Requirement Coverage
| Requirement | Plan | Verified By |
|-------------|------|-------------|
| HEAT-07 | 04-01 | Click navigates to timesheet; Vitest click test |
| INTR-01 | 04-01 | Project dropdown filters heatmap; Vitest filter test |
| TEST-04 | 04-02 | Vitest tests for click + filter interaction |
## Risks
| Risk | Mitigation |
|------|------------|
| Timesheet date filter URL format wrong | Test with actual Kimai instance; adjust format if needed |
| `/api/projects` requires different auth | API uses same session auth as the dashboard; should work |
| Dropdown styling doesn't match Kimai | Use Bootstrap `form-select` class from Tabler theme |
| Re-render flickers | Clear container and re-render; fast enough for ~365 rects |
---
*Plan created: 2026-04-08*