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

395 lines
18 KiB
Markdown

# 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<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
```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<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
```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 `<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)