--- 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 After completion, create `.planning/phases/06-renderer-architecture/06-01-SUMMARY.md`