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):
scaleQuantizesetup 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
containerdiv and only touches that. Tooltip appends todocument.bodybut 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 exportiniton 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
-
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, theModeRendererinterface can gain an optionalrenderStats()method later.
-
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.
- What we know: Phase 10 replaces the plain
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 configvitest.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)