506 lines
19 KiB
Markdown
506 lines
19 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<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>
|
|
|
|
<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.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts from Plan 01 implementation -->
|
|
|
|
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
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Click navigation tests</name>
|
|
<files>assets/test/interaction.test.ts</files>
|
|
<read_first>
|
|
- assets/src/heatmap.ts
|
|
- assets/src/types.ts
|
|
- assets/test/heatmap.test.ts
|
|
- .planning/phases/04-heatmap-interaction/04-RESEARCH.md
|
|
</read_first>
|
|
<behavior>
|
|
- 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/`
|
|
</behavior>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Click navigation tests pass: verify cell click triggers onCellClick callback, init() navigates to correct timesheet URL, project filter is included when active</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Filter dropdown tests</name>
|
|
<files>assets/test/filter.test.ts</files>
|
|
<read_first>
|
|
- assets/src/heatmap.ts
|
|
- assets/src/types.ts
|
|
- assets/test/interaction.test.ts
|
|
- assets/test/heatmap.test.ts
|
|
</read_first>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
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<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 fetch
|
|
- `container.querySelector('select.form-select')` checks dropdown presence
|
|
- `vi.waitFor()` handles async fetch/render cycles
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1</automated>
|
|
</verify>
|
|
<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 run` exits with code 0 (all tests pass including existing heatmap.test.ts)
|
|
</acceptance_criteria>
|
|
<done>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</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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)
|
|
</verification>
|
|
|
|
<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 run` exits 0
|
|
- Existing heatmap.test.ts tests unbroken
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-heatmap-interaction/04-02-SUMMARY.md`
|
|
</output>
|