diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 0fe38b9..4cf7da8 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -37,7 +37,10 @@
2. Tooltip, color scale, and cell click handler are extracted as shared utilities reusable by any renderer
3. A HeatmapState object tracks mode, display metric, and filters -- UI changes flow through state
4. Adding a new visualization mode requires only implementing a ModeRenderer interface and registering it
-**Plans**: TBD
+**Plans:** 2 plans
+Plans:
+- [ ] 06-01-PLAN.md — Type contracts, state, registry, and shared utility extraction
+- [ ] 06-02-PLAN.md — YearModeRenderer, orchestrator rewrite, test migration, visual check
### Phase 7: Mode Switcher + Week Mode
**Goal**: Users can switch between year and week visualization modes and toggle between hours and entry-count display
@@ -102,7 +105,7 @@ Note: Phases 7 and 8 can execute in parallel (both depend only on Phase 6).
| 3. Core Heatmap Rendering | v1.0 | 3/3 | Complete | 2026-04-08 |
| 4. Heatmap Interaction | v1.0 | 2/2 | Complete | 2026-04-08 |
| 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 |
-| 6. Renderer Architecture | v1.1 | 0/? | Not started | - |
+| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - |
| 7. Mode Switcher + Week Mode | v1.1 | 0/? | Not started | - |
| 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - |
| 9. Day + Combined Modes | v1.1 | 0/? | Not started | - |
diff --git a/.planning/phases/06-renderer-architecture/06-01-PLAN.md b/.planning/phases/06-renderer-architecture/06-01-PLAN.md
new file mode 100644
index 0000000..535d836
--- /dev/null
+++ b/.planning/phases/06-renderer-architecture/06-01-PLAN.md
@@ -0,0 +1,455 @@
+---
+phase: 06-renderer-architecture
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - assets/src/types.ts
+ - assets/src/state.ts
+ - assets/src/renderers/types.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/test/state.test.ts
+ - assets/test/registry.test.ts
+ - assets/test/tooltip.test.ts
+ - assets/test/color-scale.test.ts
+ - assets/test/date-utils.test.ts
+autonomous: true
+requirements: []
+
+must_haves:
+ truths:
+ - "ModeRenderer interface and RenderContext type are exported from renderers/types.ts"
+ - "HeatmapState can be created with createInitialState() and defaults to year mode, hours metric"
+ - "Renderer registry accepts registration and retrieves by mode string"
+ - "Tooltip create/show/hide functions work identically to inline code in v1.0 heatmap.ts"
+ - "Color scale builds correctly for both hours and count metrics"
+ - "Stats functions (calculateStreak, calculateStats, renderStats) produce identical output from new location"
+ - "Date utility functions (buildDateMap, generateCells, getWeekInterval) produce identical output from new location"
+ artifacts:
+ - path: "assets/src/renderers/types.ts"
+ provides: "ModeRenderer interface, RenderContext type"
+ exports: ["ModeRenderer", "RenderContext"]
+ - path: "assets/src/state.ts"
+ provides: "HeatmapState creation"
+ exports: ["createInitialState", "HeatmapState", "HeatmapMode", "DisplayMetric", "FilterState"]
+ - path: "assets/src/renderers/registry.ts"
+ provides: "Renderer registration and lookup"
+ exports: ["registerRenderer", "getRenderer"]
+ - path: "assets/src/shared/tooltip.ts"
+ provides: "Tooltip lifecycle functions"
+ exports: ["createTooltip", "showTooltip", "hideTooltip"]
+ - path: "assets/src/shared/color-scale.ts"
+ provides: "Color scale factory"
+ exports: ["buildColorScale", "FALLBACK_COLORS"]
+ - path: "assets/src/shared/stats.ts"
+ provides: "Stats calculation and rendering"
+ exports: ["calculateStreak", "calculateStats", "renderStats", "HeatmapStats"]
+ - path: "assets/src/shared/date-utils.ts"
+ provides: "Date grid utilities"
+ exports: ["buildDateMap", "generateCells", "getWeekInterval", "DayCell", "DATE_FORMAT", "DISPLAY_FORMAT"]
+ key_links:
+ - from: "assets/src/shared/color-scale.ts"
+ to: "assets/src/types.ts"
+ via: "imports DayEntry, DisplayMetric"
+ pattern: "import.*DayEntry.*DisplayMetric.*from"
+ - from: "assets/src/renderers/types.ts"
+ to: "assets/src/types.ts"
+ via: "imports HeatmapData, HeatmapConfig"
+ pattern: "import.*HeatmapData.*HeatmapConfig.*from"
+ - from: "assets/src/renderers/types.ts"
+ to: "assets/src/state.ts"
+ via: "imports HeatmapState"
+ pattern: "import.*HeatmapState.*from"
+---
+
+
+Create all building blocks for the strategy-pattern renderer system: type definitions, state management, renderer registry, and extracted shared utilities.
+
+Purpose: Establish the module structure and contracts that Plan 02 will wire together. Every function extracted here already exists in heatmap.ts -- this is pure decomposition.
+Output: 8 new source files + 5 new test files. Zero changes to runtime behavior.
+
+
+
+@/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
+
+
+
+From 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;
+}
+
+export interface ProjectOption {
+ id: number;
+ name: string;
+}
+```
+
+From assets/src/heatmap.ts (functions to extract):
+```typescript
+// Lines 47-53: buildDateMap
+function buildDateMap(days: DayEntry[]): Map { ... }
+
+// Lines 55-57: getWeekInterval
+function getWeekInterval(weekStart: string) { ... }
+
+// Lines 59-92: generateCells (returns DayCell[])
+function generateCells(begin: Date, end: Date, dateMap: Map, weekStart: string = 'monday'): DayCell[] { ... }
+
+// Lines 94-99: createTooltip
+function createTooltip(): HTMLDivElement { ... }
+
+// Lines 101-125: calculateStreak (exported)
+export function calculateStreak(days: DayEntry[]): number { ... }
+
+// Lines 127-131: HeatmapStats interface (exported)
+export interface HeatmapStats { ... }
+
+// Lines 133-148: calculateStats (exported)
+export function calculateStats(days: DayEntry[]): HeatmapStats { ... }
+
+// Lines 150-174: renderStats
+function renderStats(container: HTMLElement, days: DayEntry[]): void { ... }
+
+// Lines 200-205: color scale setup (inline in renderHeatmap)
+const colorScale = scaleQuantize().domain([0, maxHours]).range(colors);
+```
+
+
+
+
+
+
+ Task 1: Create type contracts, state module, and renderer registry
+
+ assets/src/types.ts,
+ assets/src/state.ts,
+ assets/src/renderers/types.ts,
+ assets/src/renderers/registry.ts,
+ assets/test/state.test.ts,
+ assets/test/registry.test.ts
+
+
+ assets/src/types.ts,
+ assets/src/heatmap.ts
+
+
+ - state.test.ts: createInitialState('monday') returns { mode: 'year', metric: 'hours', filters: { projectId: null, customerId: null, activityId: null }, weekStart: 'monday', data: null }
+ - state.test.ts: createInitialState('sunday') returns state with weekStart: 'sunday'
+ - registry.test.ts: registerRenderer(renderer) then getRenderer('year') returns that renderer
+ - registry.test.ts: getRenderer('nonexistent') throws Error with message containing 'Unknown heatmap mode'
+ - registry.test.ts: registering a second renderer for same mode overwrites the first
+
+
+ 1. **assets/src/types.ts** -- Add new types alongside existing ones (do NOT remove existing exports):
+ ```typescript
+ export type DisplayMetric = 'hours' | 'count';
+ export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
+
+ export interface FilterState {
+ projectId: number | null;
+ customerId: number | null;
+ activityId: number | null;
+ }
+ ```
+ Keep all existing interfaces (DayEntry, HeatmapData, HeatmapConfig, ProjectOption) unchanged.
+
+ 2. **assets/src/state.ts** -- Create state factory:
+ ```typescript
+ import type { HeatmapData, HeatmapMode, DisplayMetric, FilterState } from './types';
+
+ export interface HeatmapState {
+ mode: HeatmapMode;
+ metric: DisplayMetric;
+ filters: FilterState;
+ weekStart: string;
+ data: HeatmapData | null;
+ }
+
+ export function createInitialState(weekStart: string): HeatmapState {
+ return {
+ mode: 'year',
+ metric: 'hours',
+ filters: { projectId: null, customerId: null, activityId: null },
+ weekStart,
+ data: null,
+ };
+ }
+ ```
+ Re-export HeatmapState from this file. Keep it as a plain interface, not a class.
+
+ 3. **assets/src/renderers/types.ts** -- Create renderer contracts:
+ ```typescript
+ import type { HeatmapData, HeatmapConfig } from '../types';
+ import type { HeatmapState } from '../state';
+
+ 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;
+ }
+ ```
+
+ 4. **assets/src/renderers/registry.ts** -- Create registry:
+ ```typescript
+ import type { ModeRenderer } from './types';
+
+ const renderers = new Map();
+
+ export function registerRenderer(renderer: ModeRenderer): void {
+ renderers.set(renderer.mode, renderer);
+ }
+
+ export function getRenderer(mode: string): ModeRenderer {
+ const r = renderers.get(mode);
+ if (!r) throw new Error(`Unknown heatmap mode: ${mode}`);
+ return r;
+ }
+ ```
+
+ 5. **assets/test/state.test.ts** -- Write tests first (TDD red), then verify green:
+ - Test createInitialState returns correct default shape for 'monday' and 'sunday'
+ - Test that data is null, mode is 'year', metric is 'hours'
+
+ 6. **assets/test/registry.test.ts** -- Write tests first (TDD red), then verify green:
+ - Test registerRenderer + getRenderer roundtrip
+ - Test getRenderer throws for unknown mode
+ - Test overwrite behavior
+ - IMPORTANT: After each test run, clear the registry. Since the registry uses module-level state, add a `clearRegistry()` export for testing, or create a fresh mock renderer per test. Simplest: add `export function clearRegistry(): void { renderers.clear(); }` to registry.ts and call it in beforeEach.
+
+
+ npx vitest run assets/test/state.test.ts assets/test/registry.test.ts
+
+
+ - assets/src/types.ts contains `export type DisplayMetric = 'hours' | 'count'`
+ - assets/src/types.ts contains `export type HeatmapMode = 'year' | 'week' | 'day' | 'combined'`
+ - assets/src/types.ts contains `export interface FilterState`
+ - assets/src/types.ts still contains `export interface DayEntry` (not removed)
+ - assets/src/state.ts contains `export function createInitialState`
+ - assets/src/state.ts contains `export interface HeatmapState`
+ - assets/src/renderers/types.ts contains `export interface ModeRenderer`
+ - assets/src/renderers/types.ts contains `export interface RenderContext`
+ - assets/src/renderers/registry.ts contains `export function registerRenderer`
+ - assets/src/renderers/registry.ts contains `export function getRenderer`
+ - assets/test/state.test.ts exits 0
+ - assets/test/registry.test.ts exits 0
+ - Existing tests still pass: `npx vitest run assets/test/stats.test.ts` exits 0
+
+
+ All new type/state/registry modules exist with correct exports. New tests pass. Existing tests unaffected (heatmap.ts unchanged yet).
+
+
+
+
+ Task 2: Extract shared utilities from heatmap.ts
+
+ assets/src/shared/tooltip.ts,
+ assets/src/shared/color-scale.ts,
+ assets/src/shared/stats.ts,
+ assets/src/shared/date-utils.ts,
+ assets/test/tooltip.test.ts,
+ assets/test/color-scale.test.ts,
+ assets/test/date-utils.test.ts
+
+
+ assets/src/heatmap.ts,
+ assets/src/types.ts,
+ assets/src/state.ts
+
+
+ - tooltip.test.ts: createTooltip() returns HTMLDivElement with className 'heatmap-tooltip' and display 'none'
+ - tooltip.test.ts: createTooltip() removes any existing .heatmap-tooltip elements before creating new one
+ - tooltip.test.ts: showTooltip() sets display to 'block' and positions tooltip
+ - tooltip.test.ts: hideTooltip() sets display to 'none'
+ - color-scale.test.ts: buildColorScale with hours metric returns scaleQuantize using hours values
+ - color-scale.test.ts: buildColorScale with count metric returns scaleQuantize using count values
+ - color-scale.test.ts: buildColorScale with empty array returns scale with domain [0, 1]
+ - date-utils.test.ts: buildDateMap creates Map keyed by date string
+ - date-utils.test.ts: generateCells returns correct count for date range
+ - date-utils.test.ts: generateCells marks weekend cells correctly
+ - date-utils.test.ts: getWeekInterval returns timeMonday for 'monday', timeSunday for 'sunday'
+
+
+ Extract functions from heatmap.ts into new modules. At this point, do NOT modify heatmap.ts -- only create the new shared/ files with copied (not moved) logic. Plan 02 will do the actual switchover.
+
+ 1. **assets/src/shared/tooltip.ts** -- Extract from heatmap.ts lines 94-99 and the inline tooltip logic at lines 262-296:
+ ```typescript
+ export function createTooltip(): HTMLDivElement {
+ // Clean up stale tooltips (from heatmap.ts line 263)
+ document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());
+
+ const tip = document.createElement('div');
+ tip.className = 'heatmap-tooltip';
+ tip.style.display = 'none';
+ tip.style.position = 'fixed';
+ document.body.appendChild(tip);
+ return tip;
+ }
+
+ export function showTooltip(
+ tip: HTMLDivElement,
+ html: string,
+ anchorRect: DOMRect,
+ cellSize: number,
+ ): void {
+ tip.innerHTML = html;
+ tip.style.display = 'block';
+ tip.style.left = `${anchorRect.left + cellSize / 2}px`;
+ tip.style.top = `${anchorRect.top - tip.offsetHeight - 8}px`;
+ }
+
+ export function hideTooltip(tip: HTMLDivElement): void {
+ tip.style.display = 'none';
+ }
+ ```
+ Note: createTooltip in the new module appends to document.body and sets position: fixed (consolidating the scattered tooltip setup from renderHeatmap). The old createTooltip in heatmap.ts did NOT append or set position -- that was done inline. The new version consolidates both.
+
+ 2. **assets/src/shared/color-scale.ts** -- Extract from heatmap.ts lines 16, 200-205:
+ ```typescript
+ import { scaleQuantize } from 'd3-scale';
+ import { max } from 'd3-array';
+ import type { DayEntry, DisplayMetric } from '../types';
+
+ export const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39'];
+
+ export function buildColorScale(
+ days: DayEntry[],
+ metric: DisplayMetric = 'hours',
+ ): ReturnType> {
+ const accessor = metric === 'hours'
+ ? (d: DayEntry) => d.hours
+ : (d: DayEntry) => d.count;
+ const maxVal = max(days, accessor) || 1;
+ return scaleQuantize().domain([0, maxVal]).range(FALLBACK_COLORS);
+ }
+ ```
+ The resolveColors() function from heatmap.ts always returns FALLBACK_COLORS (the Tabler check is a no-op). Do not replicate resolveColors -- just use FALLBACK_COLORS directly.
+
+ 3. **assets/src/shared/stats.ts** -- Extract from heatmap.ts lines 101-174:
+ Copy calculateStreak, HeatmapStats interface, calculateStats, and renderStats verbatim. These functions need these imports:
+ ```typescript
+ import { timeDay } from 'd3-time';
+ import { timeFormat } from 'd3-time-format';
+ import type { DayEntry } from '../types';
+ ```
+ The DATE_FORMAT and DISPLAY_FORMAT constants used by stats (lines 24-25 of heatmap.ts) must also be available. Define them locally in stats.ts:
+ ```typescript
+ const DATE_FORMAT = timeFormat('%Y-%m-%d');
+ const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
+ ```
+
+ 4. **assets/src/shared/date-utils.ts** -- Extract from heatmap.ts lines 20-92:
+ Copy DayCell interface, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, getDayLabels, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, buildDateMap, getWeekInterval, generateCells. Exports:
+ ```typescript
+ export { DayCell, buildDateMap, getWeekInterval, generateCells, getDayLabels, DATE_FORMAT, DISPLAY_FORMAT, MONTH_FORMAT, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY };
+ ```
+ Imports needed:
+ ```typescript
+ import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
+ import { timeFormat } from 'd3-time-format';
+ import type { DayEntry } from '../types';
+ ```
+
+ 5. Write test files FIRST (TDD red), then create source files to make them green. For each test file, import from the new shared/ module path.
+
+ 6. After all new tests pass, run existing tests to confirm they still pass (heatmap.ts is unchanged, so they must).
+
+
+ npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts && npx vitest run
+
+
+ - assets/src/shared/tooltip.ts contains `export function createTooltip`
+ - assets/src/shared/tooltip.ts contains `export function showTooltip`
+ - assets/src/shared/tooltip.ts contains `export function hideTooltip`
+ - assets/src/shared/color-scale.ts contains `export const FALLBACK_COLORS`
+ - assets/src/shared/color-scale.ts contains `export function buildColorScale`
+ - assets/src/shared/color-scale.ts contains `import type { DayEntry, DisplayMetric }`
+ - assets/src/shared/stats.ts contains `export function calculateStreak`
+ - assets/src/shared/stats.ts contains `export function calculateStats`
+ - assets/src/shared/stats.ts contains `export function renderStats`
+ - assets/src/shared/stats.ts contains `export interface HeatmapStats`
+ - assets/src/shared/date-utils.ts contains `export function buildDateMap`
+ - assets/src/shared/date-utils.ts contains `export function generateCells`
+ - assets/src/shared/date-utils.ts contains `export function getWeekInterval`
+ - assets/src/shared/date-utils.ts contains `export interface DayCell`
+ - All new test files exit 0: `npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts`
+ - All existing tests still pass: `npx vitest run` exits 0
+
+
+ All shared utility modules exist with correct exports and passing tests. heatmap.ts is unchanged -- the new modules are copies, not yet wired in. Full test suite green.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Browser -> Plugin JS | Plugin JS consumes API data and renders SVG. No new boundaries in this refactor. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-06-01 | T (Tampering) | shared/tooltip.ts | accept | Tooltip uses innerHTML with data already trusted (from own API response). No new attack surface -- identical to v1.0 code. XSS risk is pre-existing and out of scope for a refactor phase. |
+| T-06-02 | D (Denial of Service) | renderers/registry.ts | accept | Registry is module-level Map populated at import time with known renderers. No user input reaches it. |
+
+
+
+1. `npx vitest run` -- all tests pass (existing + new)
+2. `npm run build` -- esbuild produces heatmap.js without errors (heatmap.ts unchanged, new files are standalone)
+3. New modules are importable: each test file successfully imports from shared/ and renderers/ paths
+
+
+
+- 8 new source files created under assets/src/ (state.ts, renderers/types.ts, renderers/registry.ts, shared/tooltip.ts, shared/color-scale.ts, shared/stats.ts, shared/date-utils.ts, plus types.ts extended)
+- 5 new test files pass (state, registry, tooltip, color-scale, date-utils)
+- 4 existing test files still pass (heatmap, stats, interaction, filter)
+- esbuild still produces valid output
+
+
+
diff --git a/.planning/phases/06-renderer-architecture/06-02-PLAN.md b/.planning/phases/06-renderer-architecture/06-02-PLAN.md
new file mode 100644
index 0000000..5563739
--- /dev/null
+++ b/.planning/phases/06-renderer-architecture/06-02-PLAN.md
@@ -0,0 +1,679 @@
+---
+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)
+
+
+