kimai-plugin-heatmap/.planning/phases/06-renderer-architecture/06-RESEARCH.md

18 KiB

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.

// 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.

// 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).

// assets/src/renderers/registry.ts
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;
}

[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

// 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

// 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<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);
}

Source: extracted from current heatmap.ts lines 200-205 [VERIFIED: codebase]

Year renderer implementing ModeRenderer

// 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

// 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 <select> with TomSelect cascading pickers.
    • What's unclear: Whether extracting filters now saves work in Phase 10.
    • Recommendation: Leave filter construction in init() for Phase 6. Phase 10 will gut it anyway. Extracting now is churn.

Sources

Primary (HIGH confidence)

  • Codebase analysis: assets/src/heatmap.ts (413 lines), assets/src/types.ts, all test files
  • package.json -- verified dependencies and build config
  • vitest.config.ts -- verified test setup

Secondary (MEDIUM confidence)

  • None needed -- this phase is purely about restructuring existing code

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- no new dependencies, verified from package.json
  • Architecture: HIGH -- based on direct analysis of 413-line source file with clear extraction seams
  • Pitfalls: HIGH -- derived from actual code patterns (tooltip cleanup, esbuild IIFE, test imports)

Research date: 2026-04-09 Valid until: 2026-05-09 (stable -- no external dependencies changing)