19 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-heatmap-interaction | 02 | execute | 2 |
|
|
true |
|
|
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.
<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/phases/04-heatmap-interaction/04-CONTEXT.md @.planning/phases/04-heatmap-interaction/04-RESEARCH.md @.planning/phases/04-heatmap-interaction/04-01-SUMMARY.mdFrom assets/src/heatmap.ts (after Plan 01):
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:
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):
// 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
renderHeatmaptests: pass anonCellClickspy viavi.fn()and verify it's called with correct dateStr - For
init()tests: mockglobal.fetchto return mock HeatmapData, setdata-url,data-timesheet-url,data-projectson container, useObject.defineProperty(window, 'location', ...)to capture href assignment
Test structure:
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
<acceptance_criteria>
- 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)
</acceptance_criteria>
Click navigation tests pass: verify cell click triggers onCellClick callback, init() navigates to correct timesheet URL, project filter is included when active
Mock strategy: Same as interaction tests — mock global.fetch and verify call arguments.
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<typeof vi.fn>;
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 fetchcontainer.querySelector('select.form-select')checks dropdown presencevi.waitFor()handles async fetch/render cycles cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1 <acceptance_criteria>- 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 runexits with code 0 (all tests pass including existing heatmap.test.ts) </acceptance_criteria> 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
<threat_model>
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 |
| </threat_model> |
<success_criteria>
- 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 runexits 0 - Existing heatmap.test.ts tests unbroken </success_criteria>