chore: archive v1.0 milestone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a074e41e81
commit
244c7c66fc
34 changed files with 1706 additions and 214 deletions
20
.planning/MILESTONES.md
Normal file
20
.planning/MILESTONES.md
Normal 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)
|
||||
|
||||
---
|
||||
|
|
@ -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*
|
||||
|
|
|
|||
46
.planning/RETROSPECTIVE.md
Normal file
46
.planning/RETROSPECTIVE.md
Normal 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 |
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
113
.planning/milestones/v1.0-ROADMAP.md
Normal file
113
.planning/milestones/v1.0-ROADMAP.md
Normal 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 |
|
||||
|
|
@ -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)_
|
||||
230
.planning/milestones/v1.0-phases/phase-1/PLAN.md
Normal file
230
.planning/milestones/v1.0-phases/phase-1/PLAN.md
Normal 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*
|
||||
486
.planning/milestones/v1.0-phases/phase-2/PLAN.md
Normal file
486
.planning/milestones/v1.0-phases/phase-2/PLAN.md
Normal 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*
|
||||
85
.planning/milestones/v1.0-phases/phase-3/03-RESEARCH.md
Normal file
85
.planning/milestones/v1.0-phases/phase-3/03-RESEARCH.md
Normal 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.
|
||||
338
.planning/milestones/v1.0-phases/phase-3/PLAN.md
Normal file
338
.planning/milestones/v1.0-phases/phase-3/PLAN.md
Normal 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*
|
||||
36
.planning/milestones/v1.0-phases/phase-4/04-RESEARCH.md
Normal file
36
.planning/milestones/v1.0-phases/phase-4/04-RESEARCH.md
Normal 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
|
||||
142
.planning/milestones/v1.0-phases/phase-4/PLAN.md
Normal file
142
.planning/milestones/v1.0-phases/phase-4/PLAN.md
Normal 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*
|
||||
Loading…
Add table
Reference in a new issue