338 lines
12 KiB
Markdown
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*
|