chore: archive v1.0 milestone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a074e41e81
commit
2547b93bff
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
|
## 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
|
## Core Value
|
||||||
|
|
||||||
|
|
@ -12,36 +12,36 @@ At a glance, see where your time went — a visual map of tracking activity that
|
||||||
|
|
||||||
### Validated
|
### 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
|
### Active
|
||||||
|
|
||||||
- [ ] Dashboard widget renders a d3.js calendar heatmap of time entries
|
|
||||||
- [ ] Heatmap cells toggle between hours-per-day and entry-count display
|
- [ ] Heatmap cells toggle between hours-per-day and entry-count display
|
||||||
- [ ] Configurable time range (not locked to a single preset)
|
- [ ] Configurable time range (not locked to a single preset)
|
||||||
- [ ] Filterable by project and/or activity
|
- [ ] Activity filtering (project filter shipped, activity deferred)
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
### Out of Scope
|
### 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
|
- 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
|
- 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
|
## Context
|
||||||
|
|
||||||
- Kimai is a self-hosted time tracking application built on Symfony
|
Shipped v1.0 with ~650 LOC source (PHP + TypeScript) and ~730 LOC tests.
|
||||||
- Plugins are Symfony bundles, installable via composer
|
Tech stack: Symfony bundle (PHP 8.2), d3.js v7 (TypeScript), esbuild, Vitest, PHPUnit.
|
||||||
- The user runs Kimai for personal time tracking, not billable client work
|
Dev environment: Nix flake with process-compose, MariaDB 11.4, Kimai 2.52.0.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
|
|
@ -54,28 +54,14 @@ At a glance, see where your time went — a visual map of tracking activity that
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| d3.js for visualization | Flexible, well-suited for calendar heatmaps, user preference | — Pending |
|
| d3.js for visualization | Flexible, well-suited for calendar heatmaps | ✓ Good |
|
||||||
| Symfony bundle (not local plugin) | Standard Kimai plugin distribution, composable | — Pending |
|
| Symfony bundle (not local plugin) | Standard Kimai plugin distribution | ✓ Good |
|
||||||
| Kimai theme colors (not custom) | Visual consistency with the rest of the dashboard | — Pending |
|
| Kimai theme CSS vars | Visual consistency with dashboard | ⚠️ Partial — color scale hardcoded green (Tabler lacks green scale vars) |
|
||||||
| TDD with PHPUnit + JS tests | Prevent regressions from AI-generated code | — Pending |
|
| TDD with PHPUnit + Vitest | Prevent regressions from AI-generated code | ✓ Good — 30+ tests |
|
||||||
| Nix flake for dev environment | Reproducible local Kimai instance, matches user's NixOS infra | — Pending |
|
| Nix flake for dev environment | Reproducible local Kimai instance | ✓ Good |
|
||||||
|
| IIFE format with KimaiHeatmap global | Browser compat without Kimai's Webpack Encore | ✓ Good |
|
||||||
## Evolution
|
| Ship prebuilt JS in Resources/public/ | Avoids hooking into Kimai's build pipeline | ✓ Good |
|
||||||
|
| Descope activity filtering | Project filter sufficient for personal use | ✓ Acceptable |
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*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
|
# 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
|
## Phases
|
||||||
|
|
||||||
**Phase Numbering:**
|
<details>
|
||||||
- Integer phases (1, 2, 3): Planned milestone work
|
<summary>✅ v1.0 MVP (Phases 1-5) — SHIPPED 2026-04-08</summary>
|
||||||
- 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 (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
|
</details>
|
||||||
- [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
|
## Progress
|
||||||
|
|
||||||
**Execution Order:**
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||||
Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5
|
|-------|-----------|----------------|--------|-----------|
|
||||||
|
| 1. Dev Environment | v1.0 | 2/2 | Complete | 2026-04-08 |
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| 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 |
|
||||||
| 1. Dev Environment | 2/2 | Done | 2026-04-08 |
|
| 4. Heatmap Interaction | v1.0 | 2/2 | Complete | 2026-04-08 |
|
||||||
| 2. Plugin Scaffold + Data Layer | 2/2 | Done | 2026-04-08 |
|
| 5. Polish | v1.0 | 2/2 | Complete | 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 |
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: MVP
|
||||||
status: verifying
|
status: shipped
|
||||||
stopped_at: Completed 04-02-PLAN.md
|
stopped_at: Milestone v1.0 complete
|
||||||
last_updated: "2026-04-08T13:35:22.113Z"
|
last_updated: "2026-04-08T21:30:00.000Z"
|
||||||
last_activity: 2026-04-08
|
last_activity: 2026-04-08
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 1
|
completed_phases: 5
|
||||||
total_plans: 4
|
total_plans: 11
|
||||||
completed_plans: 3
|
completed_plans: 11
|
||||||
percent: 75
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
@ -21,75 +21,19 @@ progress:
|
||||||
See: .planning/PROJECT.md (updated 2026-04-08)
|
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
|
**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
|
## Current Position
|
||||||
|
|
||||||
Phase: 4 (Heatmap Interaction) — EXECUTING
|
Phase: All v1.0 phases complete
|
||||||
Plan: 2 of 2
|
Plan: N/A
|
||||||
Status: Phase complete — ready for verification
|
Status: v1.0 shipped
|
||||||
Last activity: 2026-04-08
|
Last activity: 2026-04-08
|
||||||
|
|
||||||
Progress: [██░░░░░░░░] 20%
|
Progress: [██████████] 100%
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-08T13:35:22.111Z
|
Last session: 2026-04-08
|
||||||
Stopped at: Completed 04-02-PLAN.md
|
Stopped at: Milestone v1.0 complete
|
||||||
Resume file: None
|
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
|
# Requirements: Kimai Heatmap Plugin
|
||||||
|
|
||||||
**Defined:** 2026-04-08
|
**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