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

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-renderer-architecture 01 execute 1
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
true
truths artifacts key_links
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
path provides exports
assets/src/renderers/types.ts ModeRenderer interface, RenderContext type
ModeRenderer
RenderContext
path provides exports
assets/src/state.ts HeatmapState creation
createInitialState
HeatmapState
HeatmapMode
DisplayMetric
FilterState
path provides exports
assets/src/renderers/registry.ts Renderer registration and lookup
registerRenderer
getRenderer
path provides exports
assets/src/shared/tooltip.ts Tooltip lifecycle functions
createTooltip
showTooltip
hideTooltip
path provides exports
assets/src/shared/color-scale.ts Color scale factory
buildColorScale
FALLBACK_COLORS
path provides exports
assets/src/shared/stats.ts Stats calculation and rendering
calculateStreak
calculateStats
renderStats
HeatmapStats
path provides exports
assets/src/shared/date-utils.ts Date grid utilities
buildDateMap
generateCells
getWeekInterval
DayCell
DATE_FORMAT
DISPLAY_FORMAT
from to via pattern
assets/src/shared/color-scale.ts assets/src/types.ts imports DayEntry, DisplayMetric import.*DayEntry.*DisplayMetric.*from
from to via pattern
assets/src/renderers/types.ts assets/src/types.ts imports HeatmapData, HeatmapConfig import.*HeatmapData.*HeatmapConfig.*from
from to via pattern
assets/src/renderers/types.ts assets/src/state.ts imports HeatmapState 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.

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

@.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<string, DayEntry> { ... }

// Lines 55-57: getWeekInterval
function getWeekInterval(weekStart: string) { ... }

// Lines 59-92: generateCells (returns DayCell[])
function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, 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<string>().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<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;
   }
   ```

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<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);
   }
   ```
   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.

<threat_model>

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

<success_criteria>

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