12 KiB
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
- The widget displays a weeks-by-days calendar grid with cells colored by hours tracked (darker = more hours)
- Hovering any cell shows a tooltip with date, hours, and entry count
- Day-of-week labels appear on the Y-axis and month boundary labels along the top
- Days with no tracked time render with a distinct "no data" color
- 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.jsserved viabin/console assets:install --symlinkatpublic/bundles/kimaiheatmap/ - kimai.initialized event — JS fires on this event, with
kimai_context.javascriptRequestfallback for AJAX reloads - Tabler CSS variables — Use
--tblr-greenscale (--tblr-green-lt,--tblr-green,--tblr-green-darken) +--tblr-bg-surface-secondaryfor empty cells. Auto-adapts to dark mode. - Inline CSS — Ship
Resources/public/heatmap.cssalongside the JS for heatmap-specific styles (tooltip, grid layout) - Data via fetch — Template passes API URL as
data-urlattribute, 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:
{
"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:
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
{
"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
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:
export function init(container: HTMLElement): void {
console.log('Heatmap init', container);
}
Create assets/test/heatmap.test.ts:
import { describe, it, expect } from 'vitest';
describe('heatmap', () => {
it('placeholder', () => {
expect(true).toBe(true);
});
});
Verification:
npm run build— producesResources/public/heatmap.jsnpm test— 1 test passesnpx 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:
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:
- Fetch data from the URL in
data-urlattribute - Build date map — index
DayEntry[]by date string for O(1) lookup - Generate grid — iterate from
range.begintorange.end:- X position: week index (column)
- Y position: day-of-week (row, 0=Sun through 6=Sat, or Monday-start)
- Color scale —
d3.scaleSequentialmapping 0→maxHours to a 5-step green palette using CSS custom properties:- No data: CSS class
heatmap-empty→ usesvar(--tblr-bg-surface-secondary) - Low→High: 4-step green scale from
var(--tblr-green-lt)throughvar(--tblr-green-darken)
- No data: CSS class
- SVG rendering —
d3.select(container).append('svg'), thenselectAll('rect').data(days).join('rect') - Day labels — Mon, Wed, Fri text elements on left axis
- Month labels — text elements at top, positioned at first week of each month
- Tooltip — on
mouseenter, show a positioned<div>with date/hours/count; hide onmouseleave
Key implementation details:
- Use
d3.timeMondayfor 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): voidas the entry point - Handle empty data gracefully (show "No data" message)
Task 3: Create CSS
Create assets/src/heatmap.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:
- Grid structure — renders correct number of
<rect>elements for a 1-year range (~365) - Color mapping — cells with 0 hours have
heatmap-emptyclass, cells with data have fill color - Day labels — SVG contains Mon, Wed, Fri text elements
- Month labels — SVG contains month abbreviation text elements (Jan, Feb, ...)
- Tooltip — mouseenter on a cell creates tooltip div with correct content; mouseleave removes it
- Empty data — handles empty
daysarray without errors - Date range — only renders cells within the begin/end range
Test approach:
- Create a
<div>in jsdom - Call
renderHeatmap(container, mockData)(separate frominitwhich does the fetch) - Assert on the resulting DOM:
container.querySelectorAll('rect'), tooltip content, text labels
Verification:
npm test— all tests passnpm run build— produces minifiedResources/public/heatmap.jsnpx 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:
{% 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
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)
- Build JS:
npm run build - Install assets:
cd dev/kimai && bin/console assets:install public --symlink - Clear cache:
cd dev/kimai && bin/console cache:clear - Start dev stack:
process-compose -f dev/process-compose.yaml -p 0 up - Open browser:
http://127.0.0.1:8010 - 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