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

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

  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 symlinkResources/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:

{
  "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 — 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:

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 scaled3.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 renderingd3.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:

.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:

{% 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)

  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