docs(phase-6): create phase plan

This commit is contained in:
Christopher Mühl 2026-04-09 00:52:26 +02:00
parent e7d12719ed
commit a0d9bbfcc3
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
3 changed files with 1139 additions and 2 deletions

View file

@ -37,7 +37,10 @@
2. Tooltip, color scale, and cell click handler are extracted as shared utilities reusable by any renderer 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 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 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 ### Phase 7: Mode Switcher + Week Mode
**Goal**: Users can switch between year and week visualization modes and toggle between hours and entry-count display **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 | | 3. Core Heatmap Rendering | v1.0 | 3/3 | Complete | 2026-04-08 |
| 4. Heatmap Interaction | v1.0 | 2/2 | 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 | | 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 | - | | 7. Mode Switcher + Week Mode | v1.1 | 0/? | Not started | - |
| 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - | | 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - |
| 9. Day + Combined Modes | v1.1 | 0/? | Not started | - | | 9. Day + Combined Modes | v1.1 | 0/? | Not started | - |

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@/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
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-renderer-architecture/06-RESEARCH.md
<interfaces>
<!-- Current types from assets/src/types.ts that all new modules will reference -->
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<string, DayEntry> { ... }
// Lines 55-57: getWeekInterval
function getWeekInterval(weekStart: string) { ... }
// Lines 59-92: generateCells (returns DayCell[])
function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, 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<string>().domain([0, maxHours]).range(colors);
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create type contracts, state module, and renderer registry</name>
<files>
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
</files>
<read_first>
assets/src/types.ts,
assets/src/heatmap.ts
</read_first>
<behavior>
- 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
</behavior>
<action>
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<string, ModeRenderer>();
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.
</action>
<verify>
<automated>npx vitest run assets/test/state.test.ts assets/test/registry.test.ts</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
All new type/state/registry modules exist with correct exports. New tests pass. Existing tests unaffected (heatmap.ts unchanged yet).
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Extract shared utilities from heatmap.ts</name>
<files>
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
</files>
<read_first>
assets/src/heatmap.ts,
assets/src/types.ts,
assets/src/state.ts
</read_first>
<behavior>
- 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'
</behavior>
<action>
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<typeof scaleQuantize<string>> {
const accessor = metric === 'hours'
? (d: DayEntry) => d.hours
: (d: DayEntry) => d.count;
const maxVal = max(days, accessor) || 1;
return scaleQuantize<string>().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).
</action>
<verify>
<automated>npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts && npx vitest run</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/06-renderer-architecture/06-01-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@/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
</execution_context>
<context>
@.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
<interfaces>
<!-- From Plan 01 outputs -->
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<typeof scaleQuantize<string>>;
```
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<string, DayEntry>;
export function getWeekInterval(weekStart: string): typeof timeMonday;
export function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, 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; }
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create YearModeRenderer and rewrite heatmap.ts orchestrator</name>
<files>
assets/src/renderers/year.ts,
assets/src/renderers/types.ts,
assets/src/heatmap.ts
</files>
<read_first>
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
</read_first>
<action>
**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 = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${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: `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${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<HeatmapData>;
})
.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<typeof setTimeout>;
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<HeatmapData>;
})
.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.
</action>
<verify>
<automated>npm run build && npm run build:dev</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>
YearModeRenderer created. heatmap.ts rewritten as slim orchestrator. esbuild produces valid IIFE bundle with KimaiHeatmap.init intact.
</done>
</task>
<task type="auto">
<name>Task 2: Migrate test imports and verify full suite</name>
<files>
assets/test/heatmap.test.ts,
assets/test/stats.test.ts,
assets/test/interaction.test.ts,
assets/test/filter.test.ts
</files>
<read_first>
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
</read_first>
<action>
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>): 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.
</action>
<verify>
<automated>npx vitest run</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
All test imports migrated to new module locations. Full test suite (9 files) passes. esbuild produces valid bundle.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Visual regression check of rendered heatmap</name>
<files>Resources/public/heatmap.js</files>
<action>
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'
</action>
<verify>
<automated>npm run build:dev && echo "Build successful -- manual visual check required"</automated>
</verify>
<done>
Human confirms heatmap renders identically to v1.0. No visual regressions. No console errors. KimaiHeatmap.init works.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
After completion, create `.planning/phases/06-renderer-architecture/06-02-SUMMARY.md`
</output>