---
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