# Phase 6: Renderer Architecture - Research **Researched:** 2026-04-09 **Domain:** TypeScript strategy pattern refactor for d3.js multi-mode heatmap **Confidence:** HIGH ## Summary Phase 6 is a pure refactoring phase: decompose the 413-line monolithic `heatmap.ts` into a strategy-pattern renderer system where adding a new visualization mode means implementing a `ModeRenderer` interface and registering it. No new features ship -- the year-view must render identically to v1.0. The current code has clear seams for extraction. `renderHeatmap()` handles cell generation, color scaling, tooltips, and click handlers all inline. `init()` manages state (current data, active project filter) via closure variables. The refactor creates three layers: (1) a `HeatmapState` object owning mode/metric/filter state, (2) shared utilities (tooltip, color scale, cell click), and (3) mode-specific renderers that consume state and utilities. **Primary recommendation:** Use TypeScript interfaces + a renderer registry map. No external libraries needed -- this is a structural refactor of existing code with existing dependencies. ## Standard Stack ### Core (no changes from v1.0) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | d3-selection | ^3.0.0 | DOM binding | Already in use, all renderers will use it | | d3-scale | ^4.0.2 | Color quantization | Shared color scale utility | | d3-time | ^3.1.0 | Date math | Year-mode cell layout | | d3-time-format | ^4.1.0 | Date formatting | Tooltip display | | d3-array | ^3.2.4 | Data aggregation | max(), extent() for scales | ### Testing (no changes) | Library | Version | Purpose | |---------|---------|---------| | vitest | ^4.1.3 | Test runner | | jsdom | ^29.0.2 | DOM environment | ### No New Dependencies This phase adds zero new packages. The strategy pattern and state management are pure TypeScript constructs. [VERIFIED: codebase analysis] ## Architecture Patterns ### Current Structure (v1.0) ``` assets/src/ heatmap.ts # 413 lines -- everything in one file types.ts # DayEntry, HeatmapData, HeatmapConfig, ProjectOption ``` ### Target Structure (Phase 6) ``` assets/src/ types.ts # Extended with HeatmapState, ModeRenderer, DisplayMetric state.ts # HeatmapState class -- mode, metric, filters, data renderers/ registry.ts # Mode -> Renderer map, getRenderer() year.ts # YearModeRenderer -- extracted from current renderHeatmap() types.ts # ModeRenderer interface, RenderContext shared/ tooltip.ts # createTooltip(), showTooltip(), hideTooltip() color-scale.ts # buildColorScale() -- shared quantize scale factory cells.ts # Cell click handler factory, cell class builder stats.ts # calculateStreak(), calculateStats(), renderStats() date-utils.ts # generateCells(), buildDateMap(), getWeekInterval() heatmap.ts # Slim orchestrator: init() + doRender() dispatching to registry ``` ### Pattern 1: ModeRenderer Interface **What:** Each visualization mode implements a common interface. The orchestrator asks the registry for the current mode's renderer and calls `render()`. **When to use:** Every new mode (week, day, combined) implements this interface. ```typescript // assets/src/renderers/types.ts export interface RenderContext { container: HTMLElement; // The SVG area div data: HeatmapData; // Raw day entries from API state: HeatmapState; // Current mode, metric, filters config: HeatmapConfig; // Cell size, margins onCellClick?: (dateStr: string) => void; } export interface ModeRenderer { readonly mode: string; // 'year' | 'week' | 'day' | 'combined' render(ctx: RenderContext): void; destroy?(): void; // Cleanup tooltips, listeners } ``` [ASSUMED -- interface design based on strategy pattern best practices] ### Pattern 2: HeatmapState **What:** A single state object tracks the current mode, display metric, and active filters. UI changes mutate state, then trigger a re-render. **When to use:** Any time a user action changes what the heatmap displays. ```typescript // assets/src/state.ts export type DisplayMetric = 'hours' | 'count'; export type HeatmapMode = 'year' | 'week' | 'day' | 'combined'; export interface FilterState { projectId: number | null; customerId: number | null; activityId: number | null; } 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, }; } ``` [ASSUMED -- state shape designed to support Phases 7-10 requirements] ### Pattern 3: Renderer Registry **What:** A map from mode string to renderer instance. New modes register themselves; the orchestrator just does `registry.get(state.mode).render(ctx)`. ```typescript // assets/src/renderers/registry.ts 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; } ``` [ASSUMED -- standard registry pattern] ### Pattern 4: Shared Utility Extraction **What:** Tooltip, color scale, and click handler logic pulled out of `renderHeatmap()` into reusable functions. The current code has these inline: - **Tooltip** (lines 94-99, 284-296): `createTooltip()`, mouseenter/mouseleave handlers - **Color scale** (lines 200-205): `scaleQuantize` setup from data max - **Cell click** (lines 297-300): click handler calling `onCellClick(dateStr)` - **Stats** (lines 101-174): `calculateStreak()`, `calculateStats()`, `renderStats()` - **Date utils** (lines 47-92): `buildDateMap()`, `generateCells()`, `getWeekInterval()` Each becomes a standalone module importable by any renderer. ### Anti-Patterns to Avoid - **God state object with methods:** Keep state as plain data. Renderers and the orchestrator read it; only `init()` mutates it. No class with 15 methods. - **Renderer knowing about DOM outside its container:** Each renderer gets a `container` div and only touches that. Tooltip appends to `document.body` but that's the tooltip utility's concern, not the renderer's. - **Event listener leaks:** Each renderer must clean up its tooltip and event listeners in `destroy()` before a new render. The current code already handles this (`container.innerHTML = ''` and removing stale tooltips). - **Breaking the IIFE global:** The esbuild output is `--format=iife --global-name=KimaiHeatmap`. The entry point must continue to export `init` on that global. Internal module structure is esbuild's concern. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Observable state / pub-sub | Custom event system | Callback-based re-render | This widget has one consumer (the orchestrator). A reactive framework is massive overkill for a single widget. Mutate state, call `doRender()`. | | Module bundling | Custom loader | esbuild (already configured) | esbuild handles the multi-file TS -> single IIFE bundle. No config changes needed for adding files. | | Color scale | Custom interpolation | d3-scale `scaleQuantize` | Already in use. Extract, don't rewrite. | **Key insight:** This refactor is about decomposition, not new capabilities. Every function already exists in `heatmap.ts` -- the work is extraction and interface definition. ## Common Pitfalls ### Pitfall 1: Breaking the esbuild IIFE entry point **What goes wrong:** Refactoring the entry point changes what `KimaiHeatmap.init` resolves to in the browser. **Why it happens:** Moving `init()` to a different file or changing its export signature. **How to avoid:** Keep `assets/src/heatmap.ts` as the entry point. It imports everything internally and exports `init`. The esbuild command stays identical. **Warning signs:** `KimaiHeatmap.init is not a function` in browser console. ### Pitfall 2: Tooltip cleanup between renders **What goes wrong:** Stale tooltips pile up on `document.body` when switching modes or re-rendering. **Why it happens:** Each `render()` creates a new tooltip div but doesn't remove the old one. **How to avoid:** The tooltip utility must track and clean up its previous instance. Current code already does `document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove())` -- centralize this in the tooltip module. **Warning signs:** Multiple `.heatmap-tooltip` elements in DOM inspector. ### Pitfall 3: Over-engineering state management **What goes wrong:** Building a reactive store, subscriptions, or middleware for a widget that has one consumer. **Why it happens:** Applying SPA patterns to a dashboard widget. **How to avoid:** Plain object + imperative `doRender()` call. The `init()` function owns the render loop. State changes -> call `doRender()`. That's it. **Warning signs:** Words like "subscribe", "dispatch", "reducer" appearing in widget code. ### Pitfall 4: Tests coupled to implementation details **What goes wrong:** Existing tests break because they import internal functions that moved files. **Why it happens:** Tests import `calculateStreak` from `'../src/heatmap'` -- if that function moves to `'../src/shared/stats'`, imports break. **How to avoid:** Re-export moved functions from `heatmap.ts` (barrel pattern) OR update all test imports in the same commit. The second approach is cleaner -- tests should import from the module that owns the function. **Warning signs:** Test files with `import from '../src/heatmap'` for functions that now live elsewhere. ### Pitfall 5: Visual regression from refactor **What goes wrong:** SVG output changes subtly (attribute order, class names, positioning) and nobody notices. **Why it happens:** Extracting code sometimes changes initialization order or default values. **How to avoid:** Add a snapshot test that captures the SVG output of `renderHeatmap()` with known data. Compare before and after refactor. The existing tests already check cell count, classes, and tooltips -- they're good regression guards. **Warning signs:** Heatmap "looks slightly off" after deploy. ## Code Examples ### Extracting the tooltip utility ```typescript // assets/src/shared/tooltip.ts export function createTooltip(): HTMLDivElement { // Clean up any stale tooltips 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'; } ``` Source: extracted from current heatmap.ts lines 94-99, 284-296 [VERIFIED: codebase] ### Extracting the color scale ```typescript // assets/src/shared/color-scale.ts import { scaleQuantize } from 'd3-scale'; import { max } from 'd3-array'; import type { DayEntry, DisplayMetric } from '../types'; 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); } ``` Source: extracted from current heatmap.ts lines 200-205 [VERIFIED: codebase] ### Year renderer implementing ModeRenderer ```typescript // assets/src/renderers/year.ts (sketch) import type { ModeRenderer, RenderContext } from './types'; import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip'; import { buildColorScale } from '../shared/color-scale'; import { generateCells, buildDateMap, getWeekInterval } from '../shared/date-utils'; // ... d3 imports export class YearModeRenderer implements ModeRenderer { readonly mode = 'year'; private tooltip: HTMLDivElement | null = null; render(ctx: RenderContext): void { ctx.container.innerHTML = ''; this.tooltip = createTooltip(); // ... existing renderHeatmap() logic using ctx.data, ctx.state, ctx.config } destroy(): void { this.tooltip?.remove(); this.tooltip = null; } } ``` [ASSUMED -- implementation sketch, actual extraction follows current code structure] ### Slim orchestrator init ```typescript // assets/src/heatmap.ts (after refactor, sketch) import { createInitialState } 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 weekStart = container.getAttribute('data-week-start') || 'monday'; const state = createInitialState(weekStart); // ... same data-url/projects parsing as current init() const doRender = () => { if (!state.data) return; const renderer = getRenderer(state.mode); renderer.destroy?.(); renderer.render({ container: svgArea, data: state.data, state, config: DEFAULT_CONFIG, onCellClick, }); renderStats(container, state.data.days); }; // ... fetch, filter, resize logic unchanged } ``` [ASSUMED -- orchestration sketch] ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest ^4.1.3 | | Config file | `vitest.config.ts` | | Quick run command | `npm test` | | Full suite command | `npm test` | ### Phase Requirements -> Test Map Phase 6 has no formal requirement IDs (it is an architectural enabler). Testing maps to success criteria: | Criterion | Behavior | Test Type | Automated Command | File Exists? | |-----------|----------|-----------|-------------------|-------------| | SC-1 | Year-view renders identically to v1.0 | unit | `npx vitest run assets/test/heatmap.test.ts` | Yes (existing) | | SC-2 | Shared utilities are importable and work | unit | `npx vitest run assets/test/tooltip.test.ts` | No -- Wave 0 | | SC-3 | HeatmapState tracks mode/metric/filters | unit | `npx vitest run assets/test/state.test.ts` | No -- Wave 0 | | SC-4 | Registry dispatches to correct renderer | unit | `npx vitest run assets/test/registry.test.ts` | No -- Wave 0 | ### Sampling Rate - **Per task commit:** `npm test` - **Per wave merge:** `npm test` - **Phase gate:** All existing tests pass + new unit tests for extracted modules ### Wave 0 Gaps - [ ] `assets/test/state.test.ts` -- covers SC-3 (HeatmapState creation and mutation) - [ ] `assets/test/registry.test.ts` -- covers SC-4 (renderer registration and lookup) - [ ] `assets/test/tooltip.test.ts` -- covers SC-2 (tooltip create/show/hide lifecycle) - [ ] `assets/test/color-scale.test.ts` -- covers SC-2 (color scale with hours and count metrics) ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | ModeRenderer interface shape (mode, render, destroy) | Architecture Patterns | Low -- interface is internal, easily adjusted before Phase 7 | | A2 | HeatmapState includes customerId/activityId fields for future phases | Architecture Patterns | Low -- adding fields later is trivial | | A3 | DisplayMetric 'hours' vs 'count' toggle reads from state.metric | Code Examples | Low -- this is the only sensible design given VIZ-05 | | A4 | Plain object state + imperative re-render is sufficient (no reactive store) | Common Pitfalls | Medium -- if filter interactions become complex in Phase 10, may need pub/sub. But can add later. | ## Open Questions 1. **Should stats rendering move inside the renderer or stay in the orchestrator?** - What we know: Stats (streak, total, avg, busiest) are mode-agnostic in v1.0 but may differ by mode later (week-mode might show "busiest weekday" instead of "busiest day"). - What's unclear: Whether future modes need custom stats. - Recommendation: Keep `renderStats()` in the orchestrator for now. If a mode needs custom stats, the `ModeRenderer` interface can gain an optional `renderStats()` method later. 2. **Should the filter dropdown stay in `init()` or become its own module?** - What we know: Phase 10 replaces the plain `