--- phase: 04-heatmap-interaction plan: 02 type: execute wave: 2 depends_on: [04-01] files_modified: - assets/test/interaction.test.ts - assets/test/filter.test.ts autonomous: true requirements: [TEST-04] must_haves: truths: - "Vitest tests verify clicking a cell sets window.location.href to correct Kimai timesheet URL with daterange format" - "Vitest tests verify clicking a cell with active project filter includes projects[] in URL" - "Vitest tests verify filter dropdown renders with correct project options" - "Vitest tests verify selecting a project triggers fetch with ?project=N and re-renders heatmap" - "Vitest tests verify 'All Projects' resets to unfiltered fetch" - "All existing heatmap rendering tests still pass" artifacts: - path: "assets/test/interaction.test.ts" provides: "Click navigation tests for HEAT-07 and TEST-04" contains: "window.location.href" - path: "assets/test/filter.test.ts" provides: "Filter dropdown tests for INTR-01 and TEST-04" contains: "form-select" key_links: - from: "assets/test/interaction.test.ts" to: "assets/src/heatmap.ts" via: "imports renderHeatmap and init" pattern: "import.*heatmap" - from: "assets/test/filter.test.ts" to: "assets/src/heatmap.ts" via: "imports init" pattern: "import.*heatmap" --- Write Vitest tests for click navigation and project filter dropdown behavior added in Plan 01. Purpose: Verify interaction behavior automatically so regressions are caught. Covers TEST-04 requirement. Output: Two test files covering click navigation and filter dropdown, all tests green. @/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/phases/04-heatmap-interaction/04-CONTEXT.md @.planning/phases/04-heatmap-interaction/04-RESEARCH.md @.planning/phases/04-heatmap-interaction/04-01-SUMMARY.md From assets/src/heatmap.ts (after Plan 01): ```typescript export function renderHeatmap( container: HTMLElement, data: HeatmapData, config?: HeatmapConfig, onCellClick?: (dateStr: string) => void, emptyMessage?: string, ): void; export function init(container: HTMLElement): void; // init reads: data-url, data-projects (JSON array of {id, name}), data-timesheet-url // Creates .heatmap-wrapper > .heatmap-svg-area + .heatmap-filter > select.form-select ``` 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; } ``` From assets/test/heatmap.test.ts (existing test patterns): ```typescript // Uses makeMockData() helper returning HeatmapData // Uses document.createElement('div') for container // Uses container.querySelector() for DOM assertions // Dispatches MouseEvent for interaction testing ``` Task 1: Click navigation tests assets/test/interaction.test.ts - assets/src/heatmap.ts - assets/src/types.ts - assets/test/heatmap.test.ts - .planning/phases/04-heatmap-interaction/04-RESEARCH.md - Test 1: renderHeatmap with onCellClick callback — clicking a data cell calls onCellClick with the cell's dateStr - Test 2: renderHeatmap with onCellClick — clicking an empty cell calls onCellClick with the cell's dateStr - Test 3: All cells have cursor:pointer via the heatmap-cell class (verify class is applied) - Test 4: init() with data-timesheet-url — clicking a cell sets window.location.href to `{timesheetUrl}?daterange={encodeURIComponent('YYYY-MM-DD - YYYY-MM-DD')}` - Test 5: init() with active project filter — clicking a cell includes `&projects[]={id}` in the URL - Test 6: init() without data-timesheet-url — falls back to `/en/timesheet/` Create `assets/test/interaction.test.ts` with click navigation tests. **Mock strategy:** - For `renderHeatmap` tests: pass an `onCellClick` spy via `vi.fn()` and verify it's called with correct dateStr - For `init()` tests: mock `global.fetch` to return mock HeatmapData, set `data-url`, `data-timesheet-url`, `data-projects` on container, use `Object.defineProperty(window, 'location', ...)` to capture href assignment **Test structure:** ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHeatmap, init } from '../src/heatmap'; import type { HeatmapData } from '../src/types'; const MOCK_DATA: HeatmapData = { days: [ { date: '2025-01-06', hours: 2.5, count: 3 }, { date: '2025-01-07', hours: 5.0, count: 5 }, ], range: { begin: '2025-01-01', end: '2025-01-14' }, }; describe('click navigation', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); vi.restoreAllMocks(); }); it('calls onCellClick with dateStr when a data cell is clicked', () => { const onClick = vi.fn(); renderHeatmap(container, MOCK_DATA, undefined, onClick); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onClick).toHaveBeenCalledOnce(); expect(onClick.mock.calls[0][0]).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it('calls onCellClick when an empty cell is clicked', () => { const onClick = vi.fn(); renderHeatmap(container, MOCK_DATA, undefined, onClick); const emptyCell = container.querySelector('rect.heatmap-empty') as SVGRectElement; emptyCell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onClick).toHaveBeenCalledOnce(); }); it('does not throw when no onCellClick provided', () => { renderHeatmap(container, MOCK_DATA); const cell = container.querySelector('rect.heatmap-cell') as SVGRectElement; expect(() => cell.dispatchEvent(new MouseEvent('click', { bubbles: true }))).not.toThrow(); }); }); describe('init click navigation', () => { let container: HTMLDivElement; let locationHref: string; const originalLocation = window.location; beforeEach(() => { container = document.createElement('div'); container.setAttribute('data-url', '/heatmap/data'); container.setAttribute('data-timesheet-url', '/en/timesheet/'); container.setAttribute('data-projects', '[]'); document.body.appendChild(container); locationHref = ''; // Mock window.location to capture href assignment Object.defineProperty(window, 'location', { value: { ...originalLocation, get href() { return locationHref; }, set href(v: string) { locationHref = v; } }, writable: true, configurable: true, }); // Mock fetch vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(MOCK_DATA), })); }); afterEach(() => { document.body.removeChild(container); Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true }); vi.restoreAllMocks(); }); it('navigates to timesheet URL with daterange on cell click', async () => { init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('/en/timesheet/?daterange='); expect(locationHref).toMatch(/daterange=.*\d{4}-\d{2}-\d{2}.*-.*\d{4}-\d{2}-\d{2}/); }); it('includes project filter in navigation URL when active', async () => { container.setAttribute('data-projects', JSON.stringify([{ id: 5, name: 'Test Project' }])); init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); // Select the project in the dropdown const select = container.querySelector('select.form-select') as HTMLSelectElement; select.value = '5'; select.dispatchEvent(new Event('change')); // Wait for re-render after filter fetch await vi.waitFor(() => { expect(fetch).toHaveBeenCalledTimes(2); }); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('projects[]=5'); }); it('falls back to /en/timesheet/ without data-timesheet-url', async () => { container.removeAttribute('data-timesheet-url'); init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('/en/timesheet/'); }); }); ``` Adapt the exact mock patterns and async handling as needed when running against the actual implementation from Plan 01. Use `vi.waitFor()` to handle async fetch resolution. cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1 - assets/test/interaction.test.ts exists - assets/test/interaction.test.ts contains `describe('click navigation'` - assets/test/interaction.test.ts contains `window.location.href` or `locationHref` - assets/test/interaction.test.ts contains `daterange=` (verifies URL format) - assets/test/interaction.test.ts contains `projects[]=` (verifies project filter in URL) - assets/test/interaction.test.ts contains `onCellClick` (tests the callback) - `npx vitest run` exits with code 0 (all tests pass) Click navigation tests pass: verify cell click triggers onCellClick callback, init() navigates to correct timesheet URL, project filter is included when active Task 2: Filter dropdown tests assets/test/filter.test.ts - assets/src/heatmap.ts - assets/src/types.ts - assets/test/interaction.test.ts - assets/test/heatmap.test.ts - Test 1: init() renders a select element with class `form-select` when data-projects has entries - Test 2: init() does NOT render a select when data-projects is empty array - Test 3: Select has "All Projects" as first option with empty value - Test 4: Select has one option per project with id as value and name as text - Test 5: Selecting a project triggers fetch with `?project={id}` appended to data-url - Test 6: Selecting "All Projects" triggers fetch without project param - Test 7: After filter fetch, heatmap re-renders with new data (verify SVG cells exist) - Test 8: Filter dropdown has `aria-label="Filter by project"` for accessibility - Test 9: Filtered empty result shows "No tracking data for this project" message Create `assets/test/filter.test.ts` with project filter dropdown tests. **Mock strategy:** Same as interaction tests — mock `global.fetch` and verify call arguments. ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { init } from '../src/heatmap'; import type { HeatmapData } from '../src/types'; const MOCK_DATA: HeatmapData = { days: [ { date: '2025-01-06', hours: 2.5, count: 3 }, { date: '2025-01-07', hours: 5.0, count: 5 }, ], range: { begin: '2025-01-01', end: '2025-01-14' }, }; const EMPTY_DATA: HeatmapData = { days: [], range: { begin: '2025-01-01', end: '2025-01-14' }, }; const PROJECTS = [ { id: 1, name: 'Alpha' }, { id: 2, name: 'Beta' }, ]; describe('filter dropdown', () => { let container: HTMLDivElement; let fetchMock: ReturnType; beforeEach(() => { container = document.createElement('div'); container.setAttribute('data-url', '/heatmap/data'); container.setAttribute('data-timesheet-url', '/en/timesheet/'); container.setAttribute('data-projects', JSON.stringify(PROJECTS)); document.body.appendChild(container); fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(MOCK_DATA), }); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { document.body.removeChild(container); vi.restoreAllMocks(); }); it('renders select with form-select class when projects exist', async () => { init(container); const select = container.querySelector('select.form-select'); expect(select).not.toBeNull(); }); it('does not render select when no projects', async () => { container.setAttribute('data-projects', '[]'); init(container); const select = container.querySelector('select.form-select'); expect(select).toBeNull(); }); it('has "All Projects" as first option', async () => { init(container); const options = container.querySelectorAll('select.form-select option'); expect(options[0].textContent).toBe('All Projects'); expect((options[0] as HTMLOptionElement).value).toBe(''); }); it('has one option per project', async () => { init(container); const options = container.querySelectorAll('select.form-select option'); // 1 default + 2 projects = 3 expect(options.length).toBe(3); expect((options[1] as HTMLOptionElement).value).toBe('1'); expect(options[1].textContent).toBe('Alpha'); expect((options[2] as HTMLOptionElement).value).toBe('2'); expect(options[2].textContent).toBe('Beta'); }); it('has aria-label for accessibility', async () => { init(container); const select = container.querySelector('select.form-select'); expect(select?.getAttribute('aria-label')).toBe('Filter by project'); }); it('fetches with project param on selection', async () => { init(container); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); // initial fetch }); const select = container.querySelector('select.form-select') as HTMLSelectElement; select.value = '1'; select.dispatchEvent(new Event('change')); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); expect(fetchMock.mock.calls[1][0]).toBe('/heatmap/data?project=1'); }); it('fetches without project param for All Projects', async () => { init(container); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); const select = container.querySelector('select.form-select') as HTMLSelectElement; // First select a project select.value = '1'; select.dispatchEvent(new Event('change')); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); // Then select All Projects select.value = ''; select.dispatchEvent(new Event('change')); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); expect(fetchMock.mock.calls[2][0]).toBe('/heatmap/data'); }); it('re-renders heatmap after filter change', async () => { init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const select = container.querySelector('select.form-select') as HTMLSelectElement; select.value = '1'; select.dispatchEvent(new Event('change')); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); // After re-render, cells should still exist await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); }); it('shows filtered empty message when no data for project', async () => { fetchMock .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_DATA) }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(EMPTY_DATA) }); init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const select = container.querySelector('select.form-select') as HTMLSelectElement; select.value = '1'; select.dispatchEvent(new Event('change')); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(2); }); await vi.waitFor(() => { expect(container.textContent).toContain('No tracking data for this project'); }); }); }); ``` Adapt mock patterns and async handling as needed. The key assertions are: - `fetchMock.mock.calls[N][0]` checks the URL passed to fetch - `container.querySelector('select.form-select')` checks dropdown presence - `vi.waitFor()` handles async fetch/render cycles cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1 - assets/test/filter.test.ts exists - assets/test/filter.test.ts contains `describe('filter dropdown'` - assets/test/filter.test.ts contains `form-select` (verifies dropdown class) - assets/test/filter.test.ts contains `All Projects` (verifies default option) - assets/test/filter.test.ts contains `?project=` (verifies fetch URL) - assets/test/filter.test.ts contains `aria-label` (verifies accessibility) - assets/test/filter.test.ts contains `No tracking data for this project` (verifies filtered empty state) - `npx vitest run` exits with code 0 (all tests pass including existing heatmap.test.ts) Filter dropdown tests pass: dropdown renders with correct options, selecting triggers filtered fetch, re-render works, empty filtered state shows correct message, all existing tests still green ## Trust Boundaries | Boundary | Description | |----------|-------------| | N/A | Test-only plan — no production trust boundaries | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | N/A | N/A | N/A | N/A | Test files only — no attack surface | 1. `npx vitest run --reporter=verbose` exits 0 with all tests passing 2. `ls assets/test/interaction.test.ts assets/test/filter.test.ts` — both files exist 3. Test count increased from baseline (existing heatmap.test.ts tests still pass) - interaction.test.ts covers click navigation: callback, URL construction, daterange format, project filter in URL - filter.test.ts covers dropdown: rendering, option population, fetch with ?project=N, re-render, empty state - All tests pass: `npx vitest run` exits 0 - Existing heatmap.test.ts tests unbroken After completion, create `.planning/phases/04-heatmap-interaction/04-02-SUMMARY.md`