kimai-plugin-heatmap/.planning/phases/04-heatmap-interaction/04-02-PLAN.md
Christopher Mühl fc4b96c10f
docs(04): create phase plans for heatmap interaction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:25:35 +02:00

19 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-heatmap-interaction 02 execute 2
04-01
assets/test/interaction.test.ts
assets/test/filter.test.ts
true
TEST-04
truths artifacts key_links
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
path provides contains
assets/test/interaction.test.ts Click navigation tests for HEAT-07 and TEST-04 window.location.href
path provides contains
assets/test/filter.test.ts Filter dropdown tests for INTR-01 and TEST-04 form-select
from to via pattern
assets/test/interaction.test.ts assets/src/heatmap.ts imports renderHeatmap and init import.*heatmap
from to via pattern
assets/test/filter.test.ts assets/src/heatmap.ts imports init 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.

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

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

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

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.

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 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 run exits 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>
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)

<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>
After completion, create `.planning/phases/04-heatmap-interaction/04-02-SUMMARY.md`