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

29 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-renderer-architecture 02 execute 2
06-01
assets/src/heatmap.ts
assets/src/renderers/year.ts
assets/src/renderers/types.ts
assets/test/heatmap.test.ts
assets/test/stats.test.ts
assets/test/interaction.test.ts
assets/test/filter.test.ts
false
truths artifacts key_links
Year-view heatmap renders identically to v1.0 (same SVG structure, classes, attributes)
Tooltip, color scale, and cell click handler come from shared utilities, not inline code
HeatmapState object tracks mode, display metric, and filters
Adding a new mode requires only implementing ModeRenderer and calling registerRenderer()
KimaiHeatmap.init global entry point works unchanged in browser
All existing test behaviors pass from new import locations
path provides exports
assets/src/renderers/year.ts YearModeRenderer implementing ModeRenderer
YearModeRenderer
path provides exports
assets/src/heatmap.ts Slim orchestrator with init() and doRender()
init
path provides
Resources/public/heatmap.js Built IIFE bundle with KimaiHeatmap.init
from to via pattern
assets/src/heatmap.ts assets/src/renderers/registry.ts getRenderer(state.mode) getRenderer(state.mode)
from to via pattern
assets/src/heatmap.ts assets/src/renderers/year.ts registerRenderer(new YearModeRenderer()) registerRenderer.*YearModeRenderer
from to via pattern
assets/src/renderers/year.ts assets/src/shared/tooltip.ts imports createTooltip, showTooltip, hideTooltip import.*createTooltip.*from.*shared/tooltip
from to via pattern
assets/src/renderers/year.ts assets/src/shared/color-scale.ts imports buildColorScale import.*buildColorScale.*from.*shared/color-scale
from to via pattern
assets/src/renderers/year.ts assets/src/shared/date-utils.ts imports generateCells, buildDateMap, getWeekInterval import.*generateCells.*from.*shared/date-utils
Wire the building blocks from Plan 01 into a working strategy-pattern system: create YearModeRenderer, rewrite heatmap.ts as a slim orchestrator, and migrate all test imports.

Purpose: Complete the refactor so that heatmap.ts dispatches rendering through the registry and the year-view works identically to v1.0. Output: Working renderer architecture with all tests passing from new module locations.

<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 @.planning/phases/06-renderer-architecture/06-UI-SPEC.md @.planning/phases/06-renderer-architecture/06-01-SUMMARY.md From assets/src/renderers/types.ts: ```typescript 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; }


From assets/src/state.ts:
```typescript
export interface HeatmapState {
  mode: HeatmapMode;
  metric: DisplayMetric;
  filters: FilterState;
  weekStart: string;
  data: HeatmapData | null;
}

export function createInitialState(weekStart: string): HeatmapState;

From assets/src/renderers/registry.ts:

export function registerRenderer(renderer: ModeRenderer): void;
export function getRenderer(mode: string): ModeRenderer;

From assets/src/shared/tooltip.ts:

export function createTooltip(): HTMLDivElement;
export function showTooltip(tip: HTMLDivElement, html: string, anchorRect: DOMRect, cellSize: number): void;
export function hideTooltip(tip: HTMLDivElement): void;

From assets/src/shared/color-scale.ts:

export const FALLBACK_COLORS: string[];
export function buildColorScale(days: DayEntry[], metric?: DisplayMetric): ReturnType<typeof scaleQuantize<string>>;

From assets/src/shared/stats.ts:

export function calculateStreak(days: DayEntry[]): number;
export function calculateStats(days: DayEntry[]): HeatmapStats;
export interface HeatmapStats { totalHours: number; avgHours: number; busiestDay: { date: string; hours: number } | null; }
export function renderStats(container: HTMLElement, days: DayEntry[]): void;

From assets/src/shared/date-utils.ts:

export interface DayCell { date: Date; dateStr: string; entry: DayEntry | null; week: number; day: number; isWeekend: boolean; }
export function buildDateMap(days: DayEntry[]): Map<string, DayEntry>;
export function getWeekInterval(weekStart: string): typeof timeMonday;
export function generateCells(begin: Date, end: Date, dateMap: Map<string, DayEntry>, weekStart?: string): DayCell[];
export function getDayLabels(weekStart: string): string[];
export const DATE_FORMAT: (d: Date) => string;
export const DISPLAY_FORMAT: (d: Date) => string;
export const MONTH_FORMAT: (d: Date) => string;

From assets/src/types.ts:

export interface DayEntry { date: string; 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; }
export type DisplayMetric = 'hours' | 'count';
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
export interface FilterState { projectId: number | null; customerId: number | null; activityId: number | null; }
Task 1: Create YearModeRenderer and rewrite heatmap.ts orchestrator assets/src/renderers/year.ts, assets/src/renderers/types.ts, assets/src/heatmap.ts assets/src/heatmap.ts, assets/src/renderers/types.ts, assets/src/state.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/src/types.ts **Part A: Update assets/src/renderers/types.ts -- add emptyMessage to RenderContext**
Add an optional `emptyMessage?: string;` field to the RenderContext interface (after onCellClick). This preserves the filtered empty message behavior from v1.0 where "No tracking data for this project" was shown when filtering produced empty results.

**Part B: Create assets/src/renderers/year.ts**

Extract the body of `renderHeatmap()` (lines 176-302 of current heatmap.ts) into a `YearModeRenderer` class implementing `ModeRenderer`. The render() method must produce IDENTICAL SVG output.

Structure:
```typescript
import { select } from 'd3-selection';
import { timeMonth } from 'd3-time';
import { max } from 'd3-array';
import type { ModeRenderer, RenderContext } from './types';
import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip';
import { buildColorScale } from '../shared/color-scale';
import {
  buildDateMap, generateCells, getWeekInterval, getDayLabels,
  MONTH_FORMAT, DISPLAY_FORMAT, type DayCell,
} from '../shared/date-utils';

export class YearModeRenderer implements ModeRenderer {
  readonly mode = 'year';
  private tooltip: HTMLDivElement | null = null;

  render(ctx: RenderContext): void {
    ctx.container.innerHTML = '';

    // Handle empty data -- use ctx.emptyMessage if provided, else default
    if (!ctx.data.days || ctx.data.days.length === 0) {
      const msg = document.createElement('div');
      msg.textContent = ctx.emptyMessage || 'No tracking data available';
      msg.style.padding = '1rem';
      msg.style.color = 'var(--tblr-secondary, #6c757d)';
      ctx.container.appendChild(msg);
      return;
    }

    // Destroy previous tooltip
    this.destroy();
    this.tooltip = createTooltip();

    const weekStart = ctx.state.weekStart;
    const dateMap = buildDateMap(ctx.data.days);
    const begin = new Date(ctx.data.range.begin);
    const end = new Date(ctx.data.range.end);
    const cells = generateCells(begin, end, dateMap, weekStart);

    // Build color scale using state metric (hours vs count)
    const colorScale = buildColorScale(ctx.data.days, ctx.state.metric);

    const { cellGap, marginTop, marginLeft, marginBottom } = ctx.config;
    const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1;

    // Compute cell size to fill available width, capped
    const containerWidth = ctx.container.clientWidth || 800;
    const maxCellSize = 22;
    const minCellSize = 10;
    const cellSize = Math.min(maxCellSize, Math.max(minCellSize, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap));
    const step = cellSize + cellGap;
    const svgWidth = marginLeft + numWeeks * step;
    const svgHeight = marginTop + 7 * step + marginBottom;

    const wrapper = document.createElement('div');
    wrapper.style.maxWidth = `${svgWidth}px`;
    wrapper.style.margin = '0 auto';
    ctx.container.appendChild(wrapper);

    const svg = select(wrapper)
      .append('svg')
      .attr('width', svgWidth)
      .attr('height', svgHeight)
      .attr('class', 'heatmap-svg');

    // Month labels
    const weekInterval = getWeekInterval(weekStart);
    const months: { date: Date; week: number }[] = [];
    const firstWeekDay = weekInterval.floor(begin);
    timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => {
      months.push({ date: m, week: weekInterval.count(firstWeekDay, m) });
    });

    svg.selectAll('.month-label')
      .data(months)
      .join('text')
      .attr('class', 'heatmap-label month-label')
      .attr('x', (d) => marginLeft + d.week * step)
      .attr('y', marginTop - 6)
      .text((d) => MONTH_FORMAT(d.date));

    // Day labels
    svg.selectAll('.day-label')
      .data(getDayLabels(weekStart))
      .join('text')
      .attr('class', 'heatmap-label day-label')
      .attr('x', marginLeft - 6)
      .attr('y', (_d, i) => marginTop + i * step + cellSize - 2)
      .attr('text-anchor', 'end')
      .text((d) => d);

    // Cells with tooltip and click
    const tooltip = this.tooltip;
    svg.selectAll('.heatmap-cell')
      .data(cells)
      .join('rect')
      .attr('class', (d) => {
        let cls = 'heatmap-cell';
        if (!d.entry) cls += ' heatmap-empty';
        if (d.isWeekend) cls += ' heatmap-weekend';
        return cls;
      })
      .attr('x', (d) => marginLeft + d.week * step)
      .attr('y', (d) => marginTop + d.day * step)
      .attr('width', cellSize)
      .attr('height', cellSize)
      .attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : ''))
      .on('mouseenter', function (event: MouseEvent, d: DayCell) {
        const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0';
        const count = d.entry ? d.entry.count : 0;
        const html = `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`;
        const rect = (event.target as SVGRectElement).getBoundingClientRect();
        showTooltip(tooltip, html, rect, cellSize);
      })
      .on('mouseleave', function () {
        hideTooltip(tooltip);
      })
      .on('click', function (_event: MouseEvent, d: DayCell) {
        if (!ctx.onCellClick) return;
        ctx.onCellClick(d.dateStr);
      });
  }

  destroy(): void {
    this.tooltip?.remove();
    this.tooltip = null;
  }
}
```

CRITICAL details preserved from current renderHeatmap():
- Cell size computation: `Math.min(22, Math.max(10, Math.floor((containerWidth - marginLeft) / numWeeks) - cellGap))`
- SVG wrapper div with max-width and margin:0 auto
- Cell classes: `heatmap-cell`, `heatmap-empty`, `heatmap-weekend` -- same logic
- Cell `rx` and `ry` are NOT set (CSS handles border-radius) -- do NOT add them
- Color scale: uses buildColorScale(days, metric). For v1.0 the metric is always 'hours' but the infrastructure is ready for 'count'.
- Tooltip HTML: `<strong>${DISPLAY_FORMAT(d.date)}</strong><br>${hours}h (${count} entries)`
- Empty cell fill: empty string `''` (CSS `.heatmap-empty` handles it)

NOTE on color scale in cell fill: The current v1.0 code uses `colorScale(d.entry.hours)` directly. The new buildColorScale returns a scale that accepts the metric value. Since the default metric is 'hours', `colorScale(d.entry.hours)` is correct for 'hours' metric. For 'count' metric (future), the renderer would need `colorScale(d.entry.count)`. To handle both, the cell fill should be:
```typescript
.attr('fill', (d) => {
  if (!d.entry) return '';
  const val = ctx.state.metric === 'hours' ? d.entry.hours : d.entry.count;
  return colorScale(val);
})
```
This is a forward-compatible change that is a no-op for v1.0 (metric is always 'hours').

**Part C: Rewrite assets/src/heatmap.ts as slim orchestrator**

Replace the 413-line file with a ~100-line orchestrator:
- Remove ALL functions that moved to shared/ modules: buildDateMap, generateCells, getWeekInterval, createTooltip, calculateStreak, calculateStats, renderStats, getDayLabels, resolveColors, renderHeatmap
- Remove: DayCell interface, FALLBACK_COLORS, DAY_LABELS_MONDAY, DAY_LABELS_SUNDAY, MONTH_FORMAT, DATE_FORMAT, DISPLAY_FORMAT, DEFAULT_CONFIG, HeatmapStats interface
- Keep `init()` as the ONLY export (esbuild IIFE entry point)
- Import and register YearModeRenderer at module level
- Use HeatmapState to track mode/metric/filters

Complete new heatmap.ts:
```typescript
import type { HeatmapData, ProjectOption } from './types';
import { createInitialState } from './state';
import type { HeatmapState } 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 baseUrl = container.getAttribute('data-url');
  if (!baseUrl) {
    console.error('KimaiHeatmap: missing data-url attribute');
    return;
  }

  const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
  const weekStart = container.getAttribute('data-week-start') || 'monday';
  const projectsJson = container.getAttribute('data-projects');
  const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];

  const state: HeatmapState = createInitialState(weekStart);

  const onCellClick = (dateStr: string): void => {
    const daterange = `${dateStr} - ${dateStr}`;
    let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
    if (state.filters.projectId) {
      url += `&projects[]=${state.filters.projectId}`;
    }
    window.location.href = url;
  };

  // Build wrapper layout
  container.innerHTML = '';
  const wrapper = document.createElement('div');
  wrapper.className = 'heatmap-wrapper';

  const svgArea = document.createElement('div');
  svgArea.className = 'heatmap-svg-area';
  wrapper.appendChild(svgArea);

  const doRender = () => {
    if (!state.data) return;
    const renderer = getRenderer(state.mode);
    renderer.destroy?.();
    renderer.render({
      container: svgArea,
      data: state.data,
      state,
      config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 },
      onCellClick,
      emptyMessage: state.filters.projectId ? 'No tracking data for this project' : undefined,
    });
    renderStats(container, state.data.days);
    svgArea.scrollLeft = svgArea.scrollWidth;
  };

  // Build filter dropdown (only if projects exist)
  if (projects.length > 0) {
    const filterDiv = document.createElement('div');
    filterDiv.className = 'heatmap-filter';

    const selectEl = document.createElement('select');
    selectEl.className = 'form-select form-select-sm';
    selectEl.setAttribute('aria-label', 'Filter by project');

    const defaultOpt = document.createElement('option');
    defaultOpt.value = '';
    defaultOpt.textContent = 'All Projects';
    selectEl.appendChild(defaultOpt);

    for (const p of projects) {
      const opt = document.createElement('option');
      opt.value = String(p.id);
      opt.textContent = p.name;
      selectEl.appendChild(opt);
    }

    selectEl.addEventListener('change', () => {
      const val = selectEl.value;
      state.filters.projectId = val ? parseInt(val, 10) : null;
      const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;

      fetch(fetchUrl)
        .then(res => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json() as Promise<HeatmapData>;
        })
        .then(data => {
          state.data = data;
          doRender();
        })
        .catch(err => {
          console.error('KimaiHeatmap: failed to load filtered data', err);
        });
    });

    filterDiv.appendChild(selectEl);
    wrapper.appendChild(filterDiv);
  }

  container.appendChild(wrapper);

  // Re-render on window resize (debounced)
  let resizeTimer: ReturnType<typeof setTimeout>;
  window.addEventListener('resize', () => {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      if (state.data) doRender();
    }, 200);
  });

  // Initial data fetch
  fetch(baseUrl)
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<HeatmapData>;
    })
    .then(data => {
      state.data = data;
      doRender();
    })
    .catch(err => {
      console.error('KimaiHeatmap: failed to load data', err);
      svgArea.textContent = 'Failed to load heatmap data';
    });
}
```

CRITICAL: The variable name for the select element MUST be `selectEl` (not `select`) to avoid shadowing the d3 `select` import. However, since d3 `select` is no longer imported in heatmap.ts (it moved to year.ts), `select` as a variable name would work. But use `selectEl` for clarity and to avoid confusion.

CRITICAL: The `activeProjectId` closure variable from v1.0 is replaced by `state.filters.projectId`. The onCellClick handler reads from state.filters.projectId instead of a separate closure var.
npm run build && npm run build:dev - assets/src/renderers/year.ts contains `export class YearModeRenderer implements ModeRenderer` - assets/src/renderers/year.ts contains `readonly mode = 'year'` - assets/src/renderers/year.ts contains `import { createTooltip, showTooltip, hideTooltip } from '../shared/tooltip'` - assets/src/renderers/year.ts contains `import { buildColorScale } from '../shared/color-scale'` - assets/src/renderers/year.ts contains `import { buildDateMap, generateCells, getWeekInterval` (from shared/date-utils) - assets/src/renderers/types.ts contains `emptyMessage?: string` - assets/src/heatmap.ts does NOT contain `function renderHeatmap` - assets/src/heatmap.ts does NOT contain `function buildDateMap` - assets/src/heatmap.ts does NOT contain `function calculateStreak` - assets/src/heatmap.ts does NOT contain `FALLBACK_COLORS` - assets/src/heatmap.ts contains `import { createInitialState } from './state'` - assets/src/heatmap.ts contains `import { getRenderer, registerRenderer } from './renderers/registry'` - assets/src/heatmap.ts contains `import { YearModeRenderer } from './renderers/year'` - assets/src/heatmap.ts contains `registerRenderer(new YearModeRenderer())` - assets/src/heatmap.ts contains `export function init` - assets/src/heatmap.ts contains `getRenderer(state.mode)` - `npm run build` exits 0 (IIFE bundle builds successfully) - Resources/public/heatmap.js contains `KimaiHeatmap` (IIFE global intact) YearModeRenderer created. heatmap.ts rewritten as slim orchestrator. esbuild produces valid IIFE bundle with KimaiHeatmap.init intact. Task 2: Migrate test imports and verify full suite assets/test/heatmap.test.ts, assets/test/stats.test.ts, assets/test/interaction.test.ts, assets/test/filter.test.ts assets/test/heatmap.test.ts, assets/test/stats.test.ts, assets/test/interaction.test.ts, assets/test/filter.test.ts, assets/src/heatmap.ts, assets/src/renderers/year.ts, assets/src/shared/stats.ts, assets/src/shared/date-utils.ts Update all test file imports to point to new module locations. The tests themselves should NOT change logic -- only import paths and how renderHeatmap is invoked.
**assets/test/heatmap.test.ts:**
- OLD: `import { renderHeatmap } from '../src/heatmap'`
- The `renderHeatmap` function no longer exists. Rewrite this test to use YearModeRenderer directly.
- New imports:
  ```typescript
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  import { YearModeRenderer } from '../src/renderers/year';
  import type { RenderContext } from '../src/renderers/types';
  import type { HeatmapData } from '../src/types';
  import { createInitialState } from '../src/state';
  ```
- Add a `renderer` variable: `let renderer: YearModeRenderer;` initialized in beforeEach as `renderer = new YearModeRenderer();`
- Add afterEach to clean up: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
- Build RenderContext helper:
  ```typescript
  const DEFAULT_CONFIG = { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 };
  function makeCtx(data: HeatmapData, overrides?: Partial<RenderContext>): RenderContext {
    return {
      container,
      data,
      state: createInitialState('monday'),
      config: DEFAULT_CONFIG,
      ...overrides,
    };
  }
  ```
- Replace all `renderHeatmap(container, data)` with `renderer.render(makeCtx(data))`
- Replace `renderHeatmap(container, data, undefined, undefined, undefined, 'sunday')` with `renderer.render(makeCtx(data, { state: createInitialState('sunday') }))`
- Replace `renderHeatmap(container, data, undefined, onClick)` with `renderer.render(makeCtx(data, { onCellClick: onClick }))`
- All test assertions remain identical -- same SVG structure, same classes, same tooltip behavior

**assets/test/stats.test.ts:**
- OLD: `import { calculateStreak, calculateStats } from '../src/heatmap'`
- NEW: `import { calculateStreak, calculateStats } from '../src/shared/stats'`
- No other changes needed. All test logic stays the same.

**assets/test/interaction.test.ts:**
- OLD: `import { renderHeatmap, init } from '../src/heatmap'`
- The `renderHeatmap` import must change. Split into two import statements:
  ```typescript
  import { init } from '../src/heatmap';
  import { YearModeRenderer } from '../src/renderers/year';
  import { createInitialState } from '../src/state';
  import type { RenderContext } from '../src/renderers/types';
  ```
- For the `describe('click navigation')` block (tests that used renderHeatmap directly):
  - Add `let renderer: YearModeRenderer;` and initialize in beforeEach
  - Add cleanup in afterEach: `renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove());`
  - Replace `renderHeatmap(container, MOCK_DATA, undefined, onClick)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 }, onCellClick: onClick })`
  - Replace `renderHeatmap(container, MOCK_DATA)` with `renderer.render({ container, data: MOCK_DATA, state: createInitialState('monday'), config: { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 } })`
- The `describe('init click navigation')` block stays unchanged -- still uses `init` from heatmap.ts.

**assets/test/filter.test.ts:**
- OLD: `import { init } from '../src/heatmap'`
- This import is UNCHANGED -- `init` is still exported from heatmap.ts. No changes needed to this file.

After updating imports, run full test suite. All 4 existing test files + 5 new test files from Plan 01 must pass.
npx vitest run - assets/test/heatmap.test.ts does NOT contain `import { renderHeatmap } from '../src/heatmap'` - assets/test/heatmap.test.ts contains `import { YearModeRenderer } from '../src/renderers/year'` - assets/test/heatmap.test.ts contains `import { createInitialState } from '../src/state'` - assets/test/stats.test.ts contains `import { calculateStreak, calculateStats } from '../src/shared/stats'` - assets/test/stats.test.ts does NOT contain `from '../src/heatmap'` - assets/test/interaction.test.ts contains `import { YearModeRenderer }` - assets/test/interaction.test.ts does NOT contain `import { renderHeatmap` - assets/test/interaction.test.ts contains `import { init } from '../src/heatmap'` - `npx vitest run` exits 0 with all test files passing - `npm run build` exits 0 All test imports migrated to new module locations. Full test suite (9 files) passes. esbuild produces valid bundle. Task 3: Visual regression check of rendered heatmap Resources/public/heatmap.js Build the dev bundle and verify in a running Kimai instance that the heatmap renders identically to v1.0.
Steps for the human verifier:
1. Run `npm run build:dev` to build with sourcemap
2. Start local Kimai dev environment
3. Navigate to the dashboard
4. Verify the heatmap renders identically to before:
   - Cells are colored with green scale (4 shades: #9be9a8, #40c463, #30a14e, #216e39)
   - Hover shows tooltip with date, hours, entries
   - Click navigates to timesheet filtered by date
   - Stats row shows streak, total, avg, busiest
   - Filter dropdown works (if projects exist)
   - Window resize re-renders correctly
5. Open browser console -- no errors should appear
6. Check `typeof KimaiHeatmap.init` in console -- should be 'function'
npm run build:dev && echo "Build successful -- manual visual check required" Human confirms heatmap renders identically to v1.0. No visual regressions. No console errors. KimaiHeatmap.init works.

<threat_model>

Trust Boundaries

Boundary Description
Browser -> Plugin JS Unchanged from v1.0. init() reads data-* attributes and fetches from API.
API response -> Renderer JSON data rendered as SVG. Tooltip uses innerHTML with API-sourced data.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-03 T (Tampering) renderers/year.ts tooltip innerHTML accept Pre-existing v1.0 pattern. Data comes from own backend API (trusted). No user-controlled input reaches innerHTML beyond what the Kimai backend already sanitizes. Addressing XSS in tooltip is a separate concern, not introduced by this refactor.
T-06-04 S (Spoofing) heatmap.ts init() data-url attribute accept Pre-existing v1.0 pattern. data-url is set server-side in Twig template. Attacker would need to modify server response to change it.
</threat_model>
1. `npx vitest run` -- all 9 test files pass (4 existing migrated + 5 new from Plan 01) 2. `npm run build` -- IIFE bundle builds without errors 3. `npm run build:dev` -- sourcemap build works 4. Browser: KimaiHeatmap.init renders year heatmap identically to v1.0 5. Browser console: no errors on load, hover, click, filter, resize

<success_criteria>

  • YearModeRenderer exists and implements ModeRenderer interface
  • heatmap.ts is a slim orchestrator (~80-120 lines, down from 413)
  • renderHeatmap() no longer exists as a standalone function
  • All test files import from new module locations
  • Full test suite passes (9 files)
  • esbuild produces valid IIFE bundle
  • Visual regression check passes (human verification) </success_criteria>
After completion, create `.planning/phases/06-renderer-architecture/06-02-SUMMARY.md`