--- 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) After completion, create `.planning/phases/06-renderer-architecture/06-02-SUMMARY.md`