---
phase: 06-renderer-architecture
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- assets/src/types.ts
- assets/src/state.ts
- assets/src/renderers/types.ts
- assets/src/renderers/registry.ts
- assets/src/shared/tooltip.ts
- assets/src/shared/color-scale.ts
- assets/src/shared/stats.ts
- assets/src/shared/date-utils.ts
- assets/test/state.test.ts
- assets/test/registry.test.ts
- assets/test/tooltip.test.ts
- assets/test/color-scale.test.ts
- assets/test/date-utils.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "ModeRenderer interface and RenderContext type are exported from renderers/types.ts"
- "HeatmapState can be created with createInitialState() and defaults to year mode, hours metric"
- "Renderer registry accepts registration and retrieves by mode string"
- "Tooltip create/show/hide functions work identically to inline code in v1.0 heatmap.ts"
- "Color scale builds correctly for both hours and count metrics"
- "Stats functions (calculateStreak, calculateStats, renderStats) produce identical output from new location"
- "Date utility functions (buildDateMap, generateCells, getWeekInterval) produce identical output from new location"
artifacts:
- path: "assets/src/renderers/types.ts"
provides: "ModeRenderer interface, RenderContext type"
exports: ["ModeRenderer", "RenderContext"]
- path: "assets/src/state.ts"
provides: "HeatmapState creation"
exports: ["createInitialState", "HeatmapState", "HeatmapMode", "DisplayMetric", "FilterState"]
- path: "assets/src/renderers/registry.ts"
provides: "Renderer registration and lookup"
exports: ["registerRenderer", "getRenderer"]
- path: "assets/src/shared/tooltip.ts"
provides: "Tooltip lifecycle functions"
exports: ["createTooltip", "showTooltip", "hideTooltip"]
- path: "assets/src/shared/color-scale.ts"
provides: "Color scale factory"
exports: ["buildColorScale", "FALLBACK_COLORS"]
- path: "assets/src/shared/stats.ts"
provides: "Stats calculation and rendering"
exports: ["calculateStreak", "calculateStats", "renderStats", "HeatmapStats"]
- path: "assets/src/shared/date-utils.ts"
provides: "Date grid utilities"
exports: ["buildDateMap", "generateCells", "getWeekInterval", "DayCell", "DATE_FORMAT", "DISPLAY_FORMAT"]
key_links:
- from: "assets/src/shared/color-scale.ts"
to: "assets/src/types.ts"
via: "imports DayEntry, DisplayMetric"
pattern: "import.*DayEntry.*DisplayMetric.*from"
- from: "assets/src/renderers/types.ts"
to: "assets/src/types.ts"
via: "imports HeatmapData, HeatmapConfig"
pattern: "import.*HeatmapData.*HeatmapConfig.*from"
- from: "assets/src/renderers/types.ts"
to: "assets/src/state.ts"
via: "imports HeatmapState"
pattern: "import.*HeatmapState.*from"
---
Create all building blocks for the strategy-pattern renderer system: type definitions, state management, renderer registry, and extracted shared utilities.
Purpose: Establish the module structure and contracts that Plan 02 will wire together. Every function extracted here already exists in heatmap.ts -- this is pure decomposition.
Output: 8 new source files + 5 new test files. Zero changes to runtime behavior.
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/workflows/execute-plan.md
@/home/toph/code/toph/kimai-heatmap/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-renderer-architecture/06-RESEARCH.md
From assets/src/types.ts:
```typescript
export interface DayEntry {
date: string; // "YYYY-MM-DD"
hours: number;
count: number;
}
export interface HeatmapData {
days: DayEntry[];
range: {
begin: string;
end: string;
};
}
export interface HeatmapConfig {
cellSize: number;
cellGap: number;
marginTop: number;
marginLeft: number;
marginBottom: number;
}
export interface ProjectOption {
id: number;
name: string;
}
```
From assets/src/heatmap.ts (functions to extract):
```typescript
// Lines 47-53: buildDateMap
function buildDateMap(days: DayEntry[]): Map { ... }
// Lines 55-57: getWeekInterval
function getWeekInterval(weekStart: string) { ... }
// Lines 59-92: generateCells (returns DayCell[])
function generateCells(begin: Date, end: Date, dateMap: Map, weekStart: string = 'monday'): DayCell[] { ... }
// Lines 94-99: createTooltip
function createTooltip(): HTMLDivElement { ... }
// Lines 101-125: calculateStreak (exported)
export function calculateStreak(days: DayEntry[]): number { ... }
// Lines 127-131: HeatmapStats interface (exported)
export interface HeatmapStats { ... }
// Lines 133-148: calculateStats (exported)
export function calculateStats(days: DayEntry[]): HeatmapStats { ... }
// Lines 150-174: renderStats
function renderStats(container: HTMLElement, days: DayEntry[]): void { ... }
// Lines 200-205: color scale setup (inline in renderHeatmap)
const colorScale = scaleQuantize().domain([0, maxHours]).range(colors);
```
Task 1: Create type contracts, state module, and renderer registry
assets/src/types.ts,
assets/src/state.ts,
assets/src/renderers/types.ts,
assets/src/renderers/registry.ts,
assets/test/state.test.ts,
assets/test/registry.test.ts
assets/src/types.ts,
assets/src/heatmap.ts
- state.test.ts: createInitialState('monday') returns { mode: 'year', metric: 'hours', filters: { projectId: null, customerId: null, activityId: null }, weekStart: 'monday', data: null }
- state.test.ts: createInitialState('sunday') returns state with weekStart: 'sunday'
- registry.test.ts: registerRenderer(renderer) then getRenderer('year') returns that renderer
- registry.test.ts: getRenderer('nonexistent') throws Error with message containing 'Unknown heatmap mode'
- registry.test.ts: registering a second renderer for same mode overwrites the first
1. **assets/src/types.ts** -- Add new types alongside existing ones (do NOT remove existing exports):
```typescript
export type DisplayMetric = 'hours' | 'count';
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
export interface FilterState {
projectId: number | null;
customerId: number | null;
activityId: number | null;
}
```
Keep all existing interfaces (DayEntry, HeatmapData, HeatmapConfig, ProjectOption) unchanged.
2. **assets/src/state.ts** -- Create state factory:
```typescript
import type { HeatmapData, HeatmapMode, DisplayMetric, FilterState } from './types';
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,
};
}
```
Re-export HeatmapState from this file. Keep it as a plain interface, not a class.
3. **assets/src/renderers/types.ts** -- Create renderer contracts:
```typescript
import type { HeatmapData, HeatmapConfig } from '../types';
import type { HeatmapState } from '../state';
export interface RenderContext {
container: HTMLElement;
data: HeatmapData;
state: HeatmapState;
config: HeatmapConfig;
onCellClick?: (dateStr: string) => void;
}
export interface ModeRenderer {
readonly mode: string;
render(ctx: RenderContext): void;
destroy?(): void;
}
```
4. **assets/src/renderers/registry.ts** -- Create registry:
```typescript
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;
}
```
5. **assets/test/state.test.ts** -- Write tests first (TDD red), then verify green:
- Test createInitialState returns correct default shape for 'monday' and 'sunday'
- Test that data is null, mode is 'year', metric is 'hours'
6. **assets/test/registry.test.ts** -- Write tests first (TDD red), then verify green:
- Test registerRenderer + getRenderer roundtrip
- Test getRenderer throws for unknown mode
- Test overwrite behavior
- IMPORTANT: After each test run, clear the registry. Since the registry uses module-level state, add a `clearRegistry()` export for testing, or create a fresh mock renderer per test. Simplest: add `export function clearRegistry(): void { renderers.clear(); }` to registry.ts and call it in beforeEach.
npx vitest run assets/test/state.test.ts assets/test/registry.test.ts
- assets/src/types.ts contains `export type DisplayMetric = 'hours' | 'count'`
- assets/src/types.ts contains `export type HeatmapMode = 'year' | 'week' | 'day' | 'combined'`
- assets/src/types.ts contains `export interface FilterState`
- assets/src/types.ts still contains `export interface DayEntry` (not removed)
- assets/src/state.ts contains `export function createInitialState`
- assets/src/state.ts contains `export interface HeatmapState`
- assets/src/renderers/types.ts contains `export interface ModeRenderer`
- assets/src/renderers/types.ts contains `export interface RenderContext`
- assets/src/renderers/registry.ts contains `export function registerRenderer`
- assets/src/renderers/registry.ts contains `export function getRenderer`
- assets/test/state.test.ts exits 0
- assets/test/registry.test.ts exits 0
- Existing tests still pass: `npx vitest run assets/test/stats.test.ts` exits 0
All new type/state/registry modules exist with correct exports. New tests pass. Existing tests unaffected (heatmap.ts unchanged yet).
Task 2: Extract shared utilities from heatmap.ts
assets/src/shared/tooltip.ts,
assets/src/shared/color-scale.ts,
assets/src/shared/stats.ts,
assets/src/shared/date-utils.ts,
assets/test/tooltip.test.ts,
assets/test/color-scale.test.ts,
assets/test/date-utils.test.ts
assets/src/heatmap.ts,
assets/src/types.ts,
assets/src/state.ts
- tooltip.test.ts: createTooltip() returns HTMLDivElement with className 'heatmap-tooltip' and display 'none'
- tooltip.test.ts: createTooltip() removes any existing .heatmap-tooltip elements before creating new one
- tooltip.test.ts: showTooltip() sets display to 'block' and positions tooltip
- tooltip.test.ts: hideTooltip() sets display to 'none'
- color-scale.test.ts: buildColorScale with hours metric returns scaleQuantize using hours values
- color-scale.test.ts: buildColorScale with count metric returns scaleQuantize using count values
- color-scale.test.ts: buildColorScale with empty array returns scale with domain [0, 1]
- date-utils.test.ts: buildDateMap creates Map keyed by date string
- date-utils.test.ts: generateCells returns correct count for date range
- date-utils.test.ts: generateCells marks weekend cells correctly
- date-utils.test.ts: getWeekInterval returns timeMonday for 'monday', timeSunday for 'sunday'
Extract functions from heatmap.ts into new modules. At this point, do NOT modify heatmap.ts -- only create the new shared/ files with copied (not moved) logic. Plan 02 will do the actual switchover.
1. **assets/src/shared/tooltip.ts** -- Extract from heatmap.ts lines 94-99 and the inline tooltip logic at lines 262-296:
```typescript
export function createTooltip(): HTMLDivElement {
// Clean up stale tooltips (from heatmap.ts line 263)
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';
}
```
Note: createTooltip in the new module appends to document.body and sets position: fixed (consolidating the scattered tooltip setup from renderHeatmap). The old createTooltip in heatmap.ts did NOT append or set position -- that was done inline. The new version consolidates both.
2. **assets/src/shared/color-scale.ts** -- Extract from heatmap.ts lines 16, 200-205:
```typescript
import { scaleQuantize } from 'd3-scale';
import { max } from 'd3-array';
import type { DayEntry, DisplayMetric } from '../types';
export 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);
}
```
The resolveColors() function from heatmap.ts always returns FALLBACK_COLORS (the Tabler check is a no-op). Do not replicate resolveColors -- just use FALLBACK_COLORS directly.
3. **assets/src/shared/stats.ts** -- Extract from heatmap.ts lines 101-174:
Copy calculateStreak, HeatmapStats interface, calculateStats, and renderStats verbatim. These functions need these imports:
```typescript
import { timeDay } from 'd3-time';
import { timeFormat } from 'd3-time-format';
import type { DayEntry } from '../types';
```
The DATE_FORMAT and DISPLAY_FORMAT constants used by stats (lines 24-25 of heatmap.ts) must also be available. Define them locally in stats.ts:
```typescript
const DATE_FORMAT = timeFormat('%Y-%m-%d');
const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y');
```
4. **assets/src/shared/date-utils.ts** -- Extract from heatmap.ts lines 20-92:
Copy DayCell interface, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, getDayLabels, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, buildDateMap, getWeekInterval, generateCells. Exports:
```typescript
export { DayCell, buildDateMap, getWeekInterval, generateCells, getDayLabels, DATE_FORMAT, DISPLAY_FORMAT, MONTH_FORMAT, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY };
```
Imports needed:
```typescript
import { timeMonday, timeSunday, timeDay, timeMonth } from 'd3-time';
import { timeFormat } from 'd3-time-format';
import type { DayEntry } from '../types';
```
5. Write test files FIRST (TDD red), then create source files to make them green. For each test file, import from the new shared/ module path.
6. After all new tests pass, run existing tests to confirm they still pass (heatmap.ts is unchanged, so they must).
npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts && npx vitest run
- assets/src/shared/tooltip.ts contains `export function createTooltip`
- assets/src/shared/tooltip.ts contains `export function showTooltip`
- assets/src/shared/tooltip.ts contains `export function hideTooltip`
- assets/src/shared/color-scale.ts contains `export const FALLBACK_COLORS`
- assets/src/shared/color-scale.ts contains `export function buildColorScale`
- assets/src/shared/color-scale.ts contains `import type { DayEntry, DisplayMetric }`
- assets/src/shared/stats.ts contains `export function calculateStreak`
- assets/src/shared/stats.ts contains `export function calculateStats`
- assets/src/shared/stats.ts contains `export function renderStats`
- assets/src/shared/stats.ts contains `export interface HeatmapStats`
- assets/src/shared/date-utils.ts contains `export function buildDateMap`
- assets/src/shared/date-utils.ts contains `export function generateCells`
- assets/src/shared/date-utils.ts contains `export function getWeekInterval`
- assets/src/shared/date-utils.ts contains `export interface DayCell`
- All new test files exit 0: `npx vitest run assets/test/tooltip.test.ts assets/test/color-scale.test.ts assets/test/date-utils.test.ts`
- All existing tests still pass: `npx vitest run` exits 0
All shared utility modules exist with correct exports and passing tests. heatmap.ts is unchanged -- the new modules are copies, not yet wired in. Full test suite green.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> Plugin JS | Plugin JS consumes API data and renders SVG. No new boundaries in this refactor. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | T (Tampering) | shared/tooltip.ts | accept | Tooltip uses innerHTML with data already trusted (from own API response). No new attack surface -- identical to v1.0 code. XSS risk is pre-existing and out of scope for a refactor phase. |
| T-06-02 | D (Denial of Service) | renderers/registry.ts | accept | Registry is module-level Map populated at import time with known renderers. No user input reaches it. |
1. `npx vitest run` -- all tests pass (existing + new)
2. `npm run build` -- esbuild produces heatmap.js without errors (heatmap.ts unchanged, new files are standalone)
3. New modules are importable: each test file successfully imports from shared/ and renderers/ paths
- 8 new source files created under assets/src/ (state.ts, renderers/types.ts, renderers/registry.ts, shared/tooltip.ts, shared/color-scale.ts, shared/stats.ts, shared/date-utils.ts, plus types.ts extended)
- 5 new test files pass (state, registry, tooltip, color-scale, date-utils)
- 4 existing test files still pass (heatmap, stats, interaction, filter)
- esbuild still produces valid output