diff --git a/.planning/phases/06-renderer-architecture/06-RESEARCH.md b/.planning/phases/06-renderer-architecture/06-RESEARCH.md new file mode 100644 index 0000000..5979650 --- /dev/null +++ b/.planning/phases/06-renderer-architecture/06-RESEARCH.md @@ -0,0 +1,395 @@ +# 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 `