diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 0fe38b9..4cf7da8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -37,7 +37,10 @@ 2. Tooltip, color scale, and cell click handler are extracted as shared utilities reusable by any renderer 3. A HeatmapState object tracks mode, display metric, and filters -- UI changes flow through state 4. Adding a new visualization mode requires only implementing a ModeRenderer interface and registering it -**Plans**: TBD +**Plans:** 2 plans +Plans: +- [ ] 06-01-PLAN.md — Type contracts, state, registry, and shared utility extraction +- [ ] 06-02-PLAN.md — YearModeRenderer, orchestrator rewrite, test migration, visual check ### Phase 7: Mode Switcher + Week Mode **Goal**: Users can switch between year and week visualization modes and toggle between hours and entry-count display @@ -102,7 +105,7 @@ Note: Phases 7 and 8 can execute in parallel (both depend only on Phase 6). | 3. Core Heatmap Rendering | v1.0 | 3/3 | Complete | 2026-04-08 | | 4. Heatmap Interaction | v1.0 | 2/2 | Complete | 2026-04-08 | | 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 | -| 6. Renderer Architecture | v1.1 | 0/? | Not started | - | +| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - | | 7. Mode Switcher + Week Mode | v1.1 | 0/? | Not started | - | | 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - | | 9. Day + Combined Modes | v1.1 | 0/? | Not started | - | diff --git a/.planning/phases/06-renderer-architecture/06-01-PLAN.md b/.planning/phases/06-renderer-architecture/06-01-PLAN.md new file mode 100644 index 0000000..535d836 --- /dev/null +++ b/.planning/phases/06-renderer-architecture/06-01-PLAN.md @@ -0,0 +1,455 @@ +--- +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` + diff --git a/.planning/phases/06-renderer-architecture/06-02-PLAN.md b/.planning/phases/06-renderer-architecture/06-02-PLAN.md new file mode 100644 index 0000000..5563739 --- /dev/null +++ b/.planning/phases/06-renderer-architecture/06-02-PLAN.md @@ -0,0 +1,679 @@ +--- +phase: 06-renderer-architecture +plan: 02 +type: execute +wave: 2 +depends_on: ["06-01"] +files_modified: + - 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 +autonomous: false +requirements: [] + +must_haves: + truths: + - "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" + artifacts: + - path: "assets/src/renderers/year.ts" + provides: "YearModeRenderer implementing ModeRenderer" + exports: ["YearModeRenderer"] + - path: "assets/src/heatmap.ts" + provides: "Slim orchestrator with init() and doRender()" + exports: ["init"] + - path: "Resources/public/heatmap.js" + provides: "Built IIFE bundle with KimaiHeatmap.init" + key_links: + - from: "assets/src/heatmap.ts" + to: "assets/src/renderers/registry.ts" + via: "getRenderer(state.mode)" + pattern: "getRenderer\\(state\\.mode\\)" + - from: "assets/src/heatmap.ts" + to: "assets/src/renderers/year.ts" + via: "registerRenderer(new YearModeRenderer())" + pattern: "registerRenderer.*YearModeRenderer" + - from: "assets/src/renderers/year.ts" + to: "assets/src/shared/tooltip.ts" + via: "imports createTooltip, showTooltip, hideTooltip" + pattern: "import.*createTooltip.*from.*shared/tooltip" + - from: "assets/src/renderers/year.ts" + to: "assets/src/shared/color-scale.ts" + via: "imports buildColorScale" + pattern: "import.*buildColorScale.*from.*shared/color-scale" + - from: "assets/src/renderers/year.ts" + to: "assets/src/shared/date-utils.ts" + via: "imports generateCells, buildDateMap, getWeekInterval" + pattern: "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. + + + +@/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 +@.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: +```typescript +export function registerRenderer(renderer: ModeRenderer): void; +export function getRenderer(mode: string): ModeRenderer; +``` + +From assets/src/shared/tooltip.ts: +```typescript +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: +```typescript +export const FALLBACK_COLORS: string[]; +export function buildColorScale(days: DayEntry[], metric?: DisplayMetric): ReturnType>; +``` + +From assets/src/shared/stats.ts: +```typescript +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: +```typescript +export interface DayCell { date: Date; dateStr: string; entry: DayEntry | null; week: number; day: number; isWeekend: boolean; } +export function buildDateMap(days: DayEntry[]): Map; +export function getWeekInterval(weekStart: string): typeof timeMonday; +export function generateCells(begin: Date, end: Date, dateMap: Map, 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: +```typescript +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 = `${DISPLAY_FORMAT(d.date)}
${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: `${DISPLAY_FORMAT(d.date)}
${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; + }) + .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; + 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; + }) + .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 { + 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. + + + +
+ + +## 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. | + + + +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 + + + +- 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) + + + +After completion, create `.planning/phases/06-renderer-architecture/06-02-SUMMARY.md` +