---
phase: 06-renderer-architecture
plan: 02
type: execute
wave: 2
depends_on: ["06-01"]
files_modified:
- assets/src/heatmap.ts
- assets/src/renderers/year.ts
- assets/src/renderers/types.ts
- assets/test/heatmap.test.ts
- assets/test/stats.test.ts
- assets/test/interaction.test.ts
- assets/test/filter.test.ts
autonomous: false
requirements: []
must_haves:
truths:
- "Year-view heatmap renders identically to v1.0 (same SVG structure, classes, attributes)"
- "Tooltip, color scale, and cell click handler come from shared utilities, not inline code"
- "HeatmapState object tracks mode, display metric, and filters"
- "Adding a new mode requires only implementing ModeRenderer and calling registerRenderer()"
- "KimaiHeatmap.init global entry point works unchanged in browser"
- "All existing test behaviors pass from new import locations"
artifacts:
- path: "assets/src/renderers/year.ts"
provides: "YearModeRenderer implementing ModeRenderer"
exports: ["YearModeRenderer"]
- path: "assets/src/heatmap.ts"
provides: "Slim orchestrator with init() and doRender()"
exports: ["init"]
- path: "Resources/public/heatmap.js"
provides: "Built IIFE bundle with KimaiHeatmap.init"
key_links:
- from: "assets/src/heatmap.ts"
to: "assets/src/renderers/registry.ts"
via: "getRenderer(state.mode)"
pattern: "getRenderer\\(state\\.mode\\)"
- from: "assets/src/heatmap.ts"
to: "assets/src/renderers/year.ts"
via: "registerRenderer(new YearModeRenderer())"
pattern: "registerRenderer.*YearModeRenderer"
- from: "assets/src/renderers/year.ts"
to: "assets/src/shared/tooltip.ts"
via: "imports createTooltip, showTooltip, hideTooltip"
pattern: "import.*createTooltip.*from.*shared/tooltip"
- from: "assets/src/renderers/year.ts"
to: "assets/src/shared/color-scale.ts"
via: "imports buildColorScale"
pattern: "import.*buildColorScale.*from.*shared/color-scale"
- from: "assets/src/renderers/year.ts"
to: "assets/src/shared/date-utils.ts"
via: "imports generateCells, buildDateMap, getWeekInterval"
pattern: "import.*generateCells.*from.*shared/date-utils"
---
Wire the building blocks from Plan 01 into a working strategy-pattern system: create YearModeRenderer, rewrite heatmap.ts as a slim orchestrator, and migrate all test imports.
Purpose: Complete the refactor so that heatmap.ts dispatches rendering through the registry and the year-view works identically to v1.0.
Output: Working renderer architecture with all tests passing from new module locations.
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/workflows/execute-plan.md
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-renderer-architecture/06-RESEARCH.md
@.planning/phases/06-renderer-architecture/06-UI-SPEC.md
@.planning/phases/06-renderer-architecture/06-01-SUMMARY.md
From assets/src/renderers/types.ts:
```typescript
export interface RenderContext {
container: HTMLElement;
data: HeatmapData;
state: HeatmapState;
config: HeatmapConfig;
onCellClick?: (dateStr: string) => void;
}
export interface ModeRenderer {
readonly mode: string;
render(ctx: RenderContext): void;
destroy?(): void;
}
```
From assets/src/state.ts:
```typescript
export interface HeatmapState {
mode: HeatmapMode;
metric: DisplayMetric;
filters: FilterState;
weekStart: string;
data: HeatmapData | null;
}
export function createInitialState(weekStart: string): HeatmapState;
```
From assets/src/renderers/registry.ts:
```typescript
export function registerRenderer(renderer: ModeRenderer): void;
export function getRenderer(mode: string): ModeRenderer;
```
From assets/src/shared/tooltip.ts:
```typescript
export function createTooltip(): HTMLDivElement;
export function showTooltip(tip: HTMLDivElement, html: string, anchorRect: DOMRect, cellSize: number): void;
export function hideTooltip(tip: HTMLDivElement): void;
```
From assets/src/shared/color-scale.ts:
```typescript
export const FALLBACK_COLORS: string[];
export function buildColorScale(days: DayEntry[], metric?: DisplayMetric): ReturnType>;
```
From assets/src/shared/stats.ts:
```typescript
export function calculateStreak(days: DayEntry[]): number;
export function calculateStats(days: DayEntry[]): HeatmapStats;
export interface HeatmapStats { totalHours: number; avgHours: number; busiestDay: { date: string; hours: number } | null; }
export function renderStats(container: HTMLElement, days: DayEntry[]): void;
```
From assets/src/shared/date-utils.ts:
```typescript
export interface DayCell { date: Date; dateStr: string; entry: DayEntry | null; week: number; day: number; isWeekend: boolean; }
export function buildDateMap(days: DayEntry[]): Map;
export function getWeekInterval(weekStart: string): typeof timeMonday;
export function generateCells(begin: Date, end: Date, dateMap: Map, weekStart?: string): DayCell[];
export function getDayLabels(weekStart: string): string[];
export const DATE_FORMAT: (d: Date) => string;
export const DISPLAY_FORMAT: (d: Date) => string;
export const MONTH_FORMAT: (d: Date) => string;
```
From assets/src/types.ts:
```typescript
export interface DayEntry { date: string; 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; }
export interface ProjectOption { id: number; name: string; }
export type DisplayMetric = 'hours' | 'count';
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
export interface FilterState { projectId: number | null; customerId: number | null; activityId: number | null; }
```
Task 1: Create YearModeRenderer and rewrite heatmap.ts orchestrator
assets/src/renderers/year.ts,
assets/src/renderers/types.ts,
assets/src/heatmap.ts
assets/src/heatmap.ts,
assets/src/renderers/types.ts,
assets/src/state.ts,
assets/src/renderers/registry.ts,
assets/src/shared/tooltip.ts,
assets/src/shared/color-scale.ts,
assets/src/shared/stats.ts,
assets/src/shared/date-utils.ts,
assets/src/types.ts
**Part A: Update assets/src/renderers/types.ts -- add emptyMessage to RenderContext**
Add an optional `emptyMessage?: string;` field to the RenderContext interface (after onCellClick). This preserves the filtered empty message behavior from v1.0 where "No tracking data for this project" was shown when filtering produced empty results.
**Part B: Create assets/src/renderers/year.ts**
Extract the body of `renderHeatmap()` (lines 176-302 of current heatmap.ts) into a `YearModeRenderer` class implementing `ModeRenderer`. The render() method must produce IDENTICAL SVG output.
Structure:
```typescript
import { select } from 'd3-selection';
import { timeMonth } from 'd3-time';
import { max } from 'd3-array';
import type { ModeRenderer, RenderContext } from './types';
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
import { buildColorScale } from '../shared/color-scale';
import {
buildDateMap, generateCells, getWeekInterval, getDayLabels,
MONTH_FORMAT, DISPLAY_FORMAT, type DayCell,
} from '../shared/date-utils';
export class YearModeRenderer implements ModeRenderer {
readonly mode = 'year';
private tooltip: HTMLDivElement | null = null;
render(ctx: RenderContext): void {
ctx.container.innerHTML = '';
// Handle empty data -- use ctx.emptyMessage if provided, else default
if (!ctx.data.days || ctx.data.days.length === 0) {
const msg = document.createElement('div');
msg.textContent = ctx.emptyMessage || 'No tracking data available';
msg.style.padding = '1rem';
msg.style.color = 'var(--tblr-secondary, #6c757d)';
ctx.container.appendChild(msg);
return;
}
// Destroy previous tooltip
this.destroy();
this.tooltip = createTooltip();
const weekStart = ctx.state.weekStart;
const dateMap = buildDateMap(ctx.data.days);
const begin = new Date(ctx.data.range.begin);
const end = new Date(ctx.data.range.end);
const cells = generateCells(begin, end, dateMap, weekStart);
// Build color scale using state metric (hours vs count)
const colorScale = buildColorScale(ctx.data.days, ctx.state.metric);
const { cellGap, marginTop, marginLeft, marginBottom } = ctx.config;
const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;
// Compute cell size to fill available width, capped
const containerWidth = ctx.container.clientWidth || 800;
const maxCellSize = 22;
const minCellSize = 10;
const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
const step = cellSize + cellGap;
const svgWidth = marginLeft + numWeeks * step;
const svgHeight = marginTop + 7 * step + marginBottom;
const wrapper = document.createElement('div');
wrapper.style.maxWidth = `${svgWidth}px`;
wrapper.style.margin = '0 auto';
ctx.container.appendChild(wrapper);
const svg = select(wrapper)
.append('svg')
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('class', 'heatmap-svg');
// Month labels
const weekInterval = getWeekInterval(weekStart);
const months: { date: Date; week: number }[] = [];
const firstWeekDay = weekInterval.floor(begin);
timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
months.push({ date: m, week: weekInterval.count(firstWeekDay, m) });
});
svg.selectAll('.month-label')
.data(months)
.join('text')
.attr('class', 'heatmap-label month-label')
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', marginTop - 6)
.text((d) => MONTH_FORMAT(d.date));
// Day labels
svg.selectAll('.day-label')
.data(getDayLabels(weekStart))
.join('text')
.attr('class', 'heatmap-label day-label')
.attr('x', marginLeft - 6)
.attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
.attr('text-anchor', 'end')
.text((d) => d);
// Cells with tooltip and click
const tooltip = this.tooltip;
svg.selectAll('.heatmap-cell')
.data(cells)
.join('rect')
.attr('class', (d) => {
let cls = 'heatmap-cell';
if (!d.entry) cls += ' heatmap-empty';
if (d.isWeekend) cls += ' heatmap-weekend';
return cls;
})
.attr('x', (d) => marginLeft + d.week * step)
.attr('y', (d) => marginTop + d.day * step)
.attr('width', cellSize)
.attr('height', cellSize)
.attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
.on('mouseenter', function (event: MouseEvent, d: DayCell) {
const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
const count = d.entry ? d.entry.count : 0;
const html = `${DISPLAY_FORMAT(d.date)}
${hours}h (${count} entries)`;
const rect = (event.target as SVGRectElement).getBoundingClientRect();
showTooltip(tooltip, html, rect, cellSize);
})
.on('mouseleave', function () {
hideTooltip(tooltip);
})
.on('click', function (_event: MouseEvent, d: DayCell) {
if (!ctx.onCellClick) return;
ctx.onCellClick(d.dateStr);
});
}
destroy(): void {
this.tooltip?.remove();
this.tooltip = null;
}
}
```
CRITICAL details preserved from current renderHeatmap():
- Cell size computation: `Math.min(22, Math.max(10, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap))`
- SVG wrapper div with max-width and margin:0 auto
- Cell classes: `heatmap-cell`, `heatmap-empty`, `heatmap-weekend` -- same logic
- Cell `rx` and `ry` are NOT set (CSS handles border-radius) -- do NOT add them
- Color scale: uses buildColorScale(days, metric). For v1.0 the metric is always 'hours' but the infrastructure is ready for 'count'.
- Tooltip HTML: `${DISPLAY_FORMAT(d.date)}
${hours}h (${count} entries)`
- Empty cell fill: empty string `''` (CSS `.heatmap-empty` handles it)
NOTE on color scale in cell fill: The current v1.0 code uses `colorScale(d.entry.hours)` directly. The new buildColorScale returns a scale that accepts the metric value. Since the default metric is 'hours', `colorScale(d.entry.hours)` is correct for 'hours' metric. For 'count' metric (future), the renderer would need `colorScale(d.entry.count)`. To handle both, the cell fill should be:
```typescript
.attr('fill', (d) => {
if (!d.entry) return '';
const val = ctx.state.metric === 'hours' ? d.entry.hours : d.entry.count;
return colorScale(val);
})
```
This is a forward-compatible change that is a no-op for v1.0 (metric is always 'hours').
**Part C: Rewrite assets/src/heatmap.ts as slim orchestrator**
Replace the 413-line file with a ~100-line orchestrator:
- Remove ALL functions that moved to shared/ modules: buildDateMap, generateCells, getWeekInterval, createTooltip, calculateStreak, calculateStats, renderStats, getDayLabels, resolveColors, renderHeatmap
- Remove: DayCell interface, FALLBACK_COLORS, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, DEFAULT_CONFIG, HeatmapStats interface
- Keep `init()` as the ONLY export (esbuild IIFE entry point)
- Import and register YearModeRenderer at module level
- Use HeatmapState to track mode/metric/filters
Complete new heatmap.ts:
```typescript
import type { HeatmapData, ProjectOption } from './types';
import { createInitialState } from './state';
import type { HeatmapState } from './state';
import { getRenderer, registerRenderer } from './renderers/registry';
import { YearModeRenderer } from './renderers/year';
import { renderStats } from './shared/stats';
// Register built-in renderers
registerRenderer(new YearModeRenderer());
export function init(container: HTMLElement): void {
const baseUrl = container.getAttribute('data-url');
if (!baseUrl) {
console.error('KimaiHeatmap: missing data-url attribute');
return;
}
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
const weekStart = container.getAttribute('data-week-start') || 'monday';
const projectsJson = container.getAttribute('data-projects');
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
const state: HeatmapState = createInitialState(weekStart);
const onCellClick = (dateStr: string): void => {
const daterange = `${dateStr} - ${dateStr}`;
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
if (state.filters.projectId) {
url += `&projects[]=${state.filters.projectId}`;
}
window.location.href = url;
};
// Build wrapper layout
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'heatmap-wrapper';
const svgArea = document.createElement('div');
svgArea.className = 'heatmap-svg-area';
wrapper.appendChild(svgArea);
const doRender = () => {
if (!state.data) return;
const renderer = getRenderer(state.mode);
renderer.destroy?.();
renderer.render({
container: svgArea,
data: state.data,
state,
config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 },
onCellClick,
emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
});
renderStats(container, state.data.days);
svgArea.scrollLeft = svgArea.scrollWidth;
};
// Build filter dropdown (only if projects exist)
if (projects.length > 0) {
const filterDiv = document.createElement('div');
filterDiv.className = 'heatmap-filter';
const selectEl = document.createElement('select');
selectEl.className = 'form-select form-select-sm';
selectEl.setAttribute('aria-label', 'Filter by project');
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'All Projects';
selectEl.appendChild(defaultOpt);
for (const p of projects) {
const opt = document.createElement('option');
opt.value = String(p.id);
opt.textContent = p.name;
selectEl.appendChild(opt);
}
selectEl.addEventListener('change', () => {
const val = selectEl.value;
state.filters.projectId = val ? parseInt(val, 10) : null;
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
fetch(fetchUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise;
})
.then(data => {
state.data = data;
doRender();
})
.catch(err => {
console.error('KimaiHeatmap: failed to load filtered data', err);
});
});
filterDiv.appendChild(selectEl);
wrapper.appendChild(filterDiv);
}
container.appendChild(wrapper);
// Re-render on window resize (debounced)
let resizeTimer: ReturnType;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (state.data) doRender();
}, 200);
});
// Initial data fetch
fetch(baseUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise;
})
.then(data => {
state.data = data;
doRender();
})
.catch(err => {
console.error('KimaiHeatmap: failed to load data', err);
svgArea.textContent = 'Failed to load heatmap data';
});
}
```
CRITICAL: The variable name for the select element MUST be `selectEl` (not `select`) to avoid shadowing the d3 `select` import. However, since d3 `select` is no longer imported in heatmap.ts (it moved to year.ts), `select` as a variable name would work. But use `selectEl` for clarity and to avoid confusion.
CRITICAL: The `activeProjectId` closure variable from v1.0 is replaced by `state.filters.projectId`. The onCellClick handler reads from state.filters.projectId instead of a separate closure var.
npm run build && npm run build:dev
- assets/src/renderers/year.ts contains `export class YearModeRenderer implements ModeRenderer`
- assets/src/renderers/year.ts contains `readonly mode = 'year'`
- assets/src/renderers/year.ts contains `import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip'`
- assets/src/renderers/year.ts contains `import { buildColorScale } from '../shared/color-scale'`
- assets/src/renderers/year.ts contains `import { buildDateMap, generateCells, getWeekInterval` (from shared/date-utils)
- assets/src/renderers/types.ts contains `emptyMessage?: string`
- assets/src/heatmap.ts does NOT contain `function renderHeatmap`
- assets/src/heatmap.ts does NOT contain `function buildDateMap`
- assets/src/heatmap.ts does NOT contain `function calculateStreak`
- assets/src/heatmap.ts does NOT contain `FALLBACK_COLORS`
- assets/src/heatmap.ts contains `import { createInitialState } from './state'`
- assets/src/heatmap.ts contains `import { getRenderer, registerRenderer } from './renderers/registry'`
- assets/src/heatmap.ts contains `import { YearModeRenderer } from './renderers/year'`
- assets/src/heatmap.ts contains `registerRenderer(new YearModeRenderer())`
- assets/src/heatmap.ts contains `export function init`
- assets/src/heatmap.ts contains `getRenderer(state.mode)`
- `npm run build` exits 0 (IIFE bundle builds successfully)
- Resources/public/heatmap.js contains `KimaiHeatmap` (IIFE global intact)
YearModeRenderer created. heatmap.ts rewritten as slim orchestrator. esbuild produces valid IIFE bundle with KimaiHeatmap.init intact.
Task 2: Migrate test imports and verify full suite
assets/test/heatmap.test.ts,
assets/test/stats.test.ts,
assets/test/interaction.test.ts,
assets/test/filter.test.ts
assets/test/heatmap.test.ts,
assets/test/stats.test.ts,
assets/test/interaction.test.ts,
assets/test/filter.test.ts,
assets/src/heatmap.ts,
assets/src/renderers/year.ts,
assets/src/shared/stats.ts,
assets/src/shared/date-utils.ts
Update all test file imports to point to new module locations. The tests themselves should NOT change logic -- only import paths and how renderHeatmap is invoked.
**assets/test/heatmap.test.ts:**
- OLD: `import { renderHeatmap } from '../src/heatmap'`
- The `renderHeatmap` function no longer exists. Rewrite this test to use YearModeRenderer directly.
- New imports:
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { YearModeRenderer } from '../src/renderers/year';
import type { RenderContext } from '../src/renderers/types';
import type { HeatmapData } from '../src/types';
import { createInitialState } from '../src/state';
```
- Add a `renderer` variable: `let renderer: YearModeRenderer;` initialized in beforeEach as `renderer = new YearModeRenderer();`
- Add afterEach to clean up: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
- Build RenderContext helper:
```typescript
const DEFAULT_CONFIG = { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 };
function makeCtx(data: HeatmapData, overrides?: Partial): RenderContext {
return {
container,
data,
state: createInitialState('monday'),
config: DEFAULT_CONFIG,
...overrides,
};
}
```
- Replace all `renderHeatmap(container, data)` with `renderer.render(makeCtx(data))`
- Replace `renderHeatmap(container, data, undefined, undefined, undefined, 'sunday')` with `renderer.render(makeCtx(data, { state: createInitialState('sunday') }))`
- Replace `renderHeatmap(container, data, undefined, onClick)` with `renderer.render(makeCtx(data, { onCellClick: onClick }))`
- All test assertions remain identical -- same SVG structure, same classes, same tooltip behavior
**assets/test/stats.test.ts:**
- OLD: `import { calculateStreak, calculateStats } from '../src/heatmap'`
- NEW: `import { calculateStreak, calculateStats } from '../src/shared/stats'`
- No other changes needed. All test logic stays the same.
**assets/test/interaction.test.ts:**
- OLD: `import { renderHeatmap, init } from '../src/heatmap'`
- The `renderHeatmap` import must change. Split into two import statements:
```typescript
import { init } from '../src/heatmap';
import { YearModeRenderer } from '../src/renderers/year';
import { createInitialState } from '../src/state';
import type { RenderContext } from '../src/renderers/types';
```
- For the `describe('click navigation')` block (tests that used renderHeatmap directly):
- Add `let renderer: YearModeRenderer;` and initialize in beforeEach
- Add cleanup in afterEach: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
- Replace `renderHeatmap(container, MOCK_DATA, undefined, onClick)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 }, onCellClick: onClick })`
- Replace `renderHeatmap(container, MOCK_DATA)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 } })`
- The `describe('init click navigation')` block stays unchanged -- still uses `init` from heatmap.ts.
**assets/test/filter.test.ts:**
- OLD: `import { init } from '../src/heatmap'`
- This import is UNCHANGED -- `init` is still exported from heatmap.ts. No changes needed to this file.
After updating imports, run full test suite. All 4 existing test files + 5 new test files from Plan 01 must pass.
npx vitest run
- assets/test/heatmap.test.ts does NOT contain `import { renderHeatmap } from '../src/heatmap'`
- assets/test/heatmap.test.ts contains `import { YearModeRenderer } from '../src/renderers/year'`
- assets/test/heatmap.test.ts contains `import { createInitialState } from '../src/state'`
- assets/test/stats.test.ts contains `import { calculateStreak, calculateStats } from '../src/shared/stats'`
- assets/test/stats.test.ts does NOT contain `from '../src/heatmap'`
- assets/test/interaction.test.ts contains `import { YearModeRenderer }`
- assets/test/interaction.test.ts does NOT contain `import { renderHeatmap`
- assets/test/interaction.test.ts contains `import { init } from '../src/heatmap'`
- `npx vitest run` exits 0 with all test files passing
- `npm run build` exits 0
All test imports migrated to new module locations. Full test suite (9 files) passes. esbuild produces valid bundle.
Task 3: Visual regression check of rendered heatmap
Resources/public/heatmap.js
Build the dev bundle and verify in a running Kimai instance that the heatmap renders identically to v1.0.
Steps for the human verifier:
1. Run `npm run build:dev` to build with sourcemap
2. Start local Kimai dev environment
3. Navigate to the dashboard
4. Verify the heatmap renders identically to before:
- Cells are colored with green scale (4 shades: #9be9a8, #40c463, #30a14e, #216e39)
- Hover shows tooltip with date, hours, entries
- Click navigates to timesheet filtered by date
- Stats row shows streak, total, avg, busiest
- Filter dropdown works (if projects exist)
- Window resize re-renders correctly
5. Open browser console -- no errors should appear
6. Check `typeof KimaiHeatmap.init` in console -- should be 'function'
npm run build:dev && echo "Build successful -- manual visual check required"
Human confirms heatmap renders identically to v1.0. No visual regressions. No console errors. KimaiHeatmap.init works.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> Plugin JS | Unchanged from v1.0. init() reads data-* attributes and fetches from API. |
| API response -> Renderer | JSON data rendered as SVG. Tooltip uses innerHTML with API-sourced data. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-03 | T (Tampering) | renderers/year.ts tooltip innerHTML | accept | Pre-existing v1.0 pattern. Data comes from own backend API (trusted). No user-controlled input reaches innerHTML beyond what the Kimai backend already sanitizes. Addressing XSS in tooltip is a separate concern, not introduced by this refactor. |
| T-06-04 | S (Spoofing) | heatmap.ts init() data-url attribute | accept | Pre-existing v1.0 pattern. data-url is set server-side in Twig template. Attacker would need to modify server response to change it. |
1. `npx vitest run` -- all 9 test files pass (4 existing migrated + 5 new from Plan 01)
2. `npm run build` -- IIFE bundle builds without errors
3. `npm run build:dev` -- sourcemap build works
4. Browser: KimaiHeatmap.init renders year heatmap identically to v1.0
5. Browser console: no errors on load, hover, click, filter, resize
- YearModeRenderer exists and implements ModeRenderer interface
- heatmap.ts is a slim orchestrator (~80-120 lines, down from 413)
- renderHeatmap() no longer exists as a standalone function
- All test files import from new module locations
- Full test suite passes (9 files)
- esbuild produces valid IIFE bundle
- Visual regression check passes (human verification)