kimai-plugin-heatmap/.planning/milestones/v1.0-phases/phase-3/PLAN.md
Christopher Mühl 244c7c66fc
chore: archive v1.0 milestone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 23:25:26 +02:00

338 lines
12 KiB
Markdown

# 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*