kimai-plugin-heatmap/.planning/phases/04-heatmap-interaction/04-RESEARCH.md

20 KiB

Phase 4: Heatmap Interaction - Research

Researched: 2026-04-08 Domain: d3.js click interaction, Kimai timesheet URL routing, Symfony data passing to Twig/JS Confidence: HIGH

Summary

Phase 4 adds click-through navigation from heatmap day cells to Kimai's timesheet view and a project filter dropdown that re-fetches and re-renders the heatmap. The codebase is well-prepared: renderHeatmap() already accepts data and re-renders from scratch (clear + redraw pattern), and the HeatmapController::data() endpoint already accepts ?project=N.

The main technical findings are: (1) Kimai's timesheet daterange URL format is locale-dependent and uses a begin - end range, but Kimai always accepts the fallback yyyy-MM-dd format, so we can safely use YYYY-MM-DD - YYYY-MM-DD with the same date for both begin and end to filter to a single day; (2) the project list should be passed as a data-projects JSON attribute on the container div, populated by the widget/Twig template using ProjectRepository; (3) existing test infrastructure (Vitest + jsdom) is sufficient for all new interaction tests.

Primary recommendation: Add click handlers to d3 cells during rendering, build the filter dropdown as a plain <select> with Tabler classes, and test everything with Vitest by mocking window.location and fetch.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Clicking any day cell navigates to Kimai's timesheet list filtered by that date (/en/timesheet/?daterange=YYYY-MM-DD)
  • Empty cells (no data) are clickable too -- user may want to add time for that day
  • Pointer cursor + subtle hover highlight (opacity change) on all cells as click affordance
  • If a project filter is active, preserve it in the timesheet URL (&projects[]=N)
  • Filter dropdown positioned next to the heatmap SVG (right side), using the spare horizontal space
  • Projects only -- no activity filter (API already supports ?project=N)
  • Default state: "All Projects" selected, no filter applied
  • Selecting a project fetches from API with ?project=N, re-renders heatmap in place; color scale recalculates for filtered data
  • Vitest tests for click navigation: verify click handler sets window.location.href to correct Kimai URL pattern
  • Vitest tests for filter dropdown: verify dropdown renders, selecting fires fetch with correct query param, heatmap re-renders with new data

Claude's Discretion

  • Exact Kimai timesheet URL format (verify against Kimai source)
  • Filter dropdown styling details (should use Tabler/Kimai form classes)
  • How to populate the project list (new API endpoint or extend existing one)

Deferred Ideas (OUT OF SCOPE)

None -- discussion stayed within phase scope.

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
HEAT-07 Clicking a day cell navigates to Kimai timesheet view filtered to that date Verified Kimai timesheet URL pattern: /en/timesheet/?daterange={date} - {date} with fallback yyyy-MM-dd format always accepted
INTR-01 Dropdown filter to view heatmap for a specific project or activity API endpoint already supports ?project=N; project list via data-projects JSON attribute from Twig template
TEST-04 JavaScript tests for tooltip and click interaction behavior Existing Vitest + jsdom setup; mock window.location.href and global.fetch

</phase_requirements>

Standard Stack

No new packages needed. All work uses existing dependencies.

Core (already installed)

Library Version Purpose Why Standard
d3-selection ^3.0.0 Click event handlers on SVG rects Already used for mouseenter/mouseleave; .on('click', ...) is the same API
vitest ^4.1.3 Interaction tests Already configured with jsdom environment

Supporting (already installed)

Library Version Purpose When to Use
esbuild ^0.28.0 Rebuild bundled JS after adding interaction code After any TypeScript changes

No npm install needed for this phase. [VERIFIED: package.json in project root]

Architecture Patterns

Kimai Timesheet URL Format (CRITICAL)

Kimai's daterange parameter is locale-dependent but always accepts the fallback format yyyy-MM-dd. The format is a range (begin - end), not a single date. To filter to one day, use the same date for both begin and end. [VERIFIED: dev/kimai/src/Form/Type/DateRangeType.php lines 200-201]

# Single day navigation:
/en/timesheet/?daterange=2026-04-08 - 2026-04-08

# With project filter:
/en/timesheet/?daterange=2026-04-08 - 2026-04-08&projects[]=5

The DATE_SPACER constant is - (space-dash-space). [VERIFIED: DateRangeType::DATE_SPACER]

Note: The CONTEXT.md says /en/timesheet/?daterange=YYYY-MM-DD but Kimai actually requires a range format. Use YYYY-MM-DD - YYYY-MM-DD with the same date repeated. The /en/ locale prefix is correct for the default English locale. [VERIFIED: route name timesheet at path /timesheet/]

Click Handler Pattern

Add .on('click', ...) to the existing d3 cell selection chain in renderHeatmap(). The click handler needs access to:

  • d.dateStr -- the YYYY-MM-DD date string (already in DayCell interface)
  • The base URL for the Kimai instance (derived from window.location.origin)
  • The currently selected project filter ID (if any)
// Source: existing heatmap.ts pattern + d3-selection API
.on('click', function (_event: MouseEvent, d: DayCell) {
  const date = d.dateStr;
  const daterange = `${date} - ${date}`;
  let url = `/en/timesheet/?daterange=${encodeURIComponent(daterange)}`;
  if (activeProjectId) {
    url += `&projects[]=${activeProjectId}`;
  }
  window.location.href = url;
})

Filter Dropdown Pattern

The filter dropdown is a native <select> element, not an SVG element. It sits outside the SVG in the DOM, styled with Tabler CSS classes. The project list comes from a data-projects attribute on the container div.

// Parse project list from container attribute
const projectsJson = container.getAttribute('data-projects');
const projects: Array<{id: number; name: string}> = projectsJson ? JSON.parse(projectsJson) : [];

// Create <select> element
const select = document.createElement('select');
select.className = 'form-select form-select-sm';
select.setAttribute('aria-label', 'Filter by project');

// Default option
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'All Projects';
select.appendChild(defaultOpt);

// Project options
for (const p of projects) {
  const opt = document.createElement('option');
  opt.value = String(p.id);
  opt.textContent = p.name;
  select.appendChild(opt);
}

Layout Change

The current template has a single #heatmap-container div. This needs to become a flex container wrapping the SVG area and the filter. Two approaches:

Option A (recommended): Modify renderHeatmap to create the flex wrapper and filter internally. This keeps all DOM manipulation in one place and avoids requiring Twig template changes for the layout.

Option B: Add the wrapper structure in the Twig template. This couples template and JS more tightly.

Recommendation: Option A -- the JS already owns the container's innerHTML (it clears it on render). Add a wrapper div structure inside the JS.

Twig Template Changes

The Twig template needs a new data-projects attribute. This requires the widget to pass project data to the template.

{# heatmap.html.twig #}
<div id="heatmap-container"
     data-url="{{ path('heatmap_data') }}"
     data-projects="{{ projects|json_encode }}"
     style="min-height: 150px; overflow-x: auto;">
</div>

The projects variable must be provided by the widget or a Twig extension.

Project List Data Source

Recommended approach: Add a method to HeatmapService that queries the user's projects from the timesheet table (projects they've actually tracked time for), rather than all visible projects. This gives a more relevant dropdown. [ASSUMED]

// In HeatmapService
public function getUserProjects(User $user): array
{
    $qb = $this->repository->createQueryBuilder('t');
    $qb->select('DISTINCT IDENTITY(t.project) as projectId, p.name')
       ->join('t.project', 'p')
       ->andWhere($qb->expr()->eq('t.user', ':user'))
       ->andWhere($qb->expr()->isNotNull('t.end'))
       ->setParameter('user', $user)
       ->orderBy('p.name', 'ASC');

    return array_map(fn(array $row) => [
        'id' => (int) $row['projectId'],
        'name' => $row['name'],
    ], $qb->getQuery()->getResult());
}

The widget needs to pass this data to the template. Since HeatmapWidget::getData() currently returns null, it can be updated to return the project list. Alternatively, inject data through Twig globals or a controller. [ASSUMED]

Simplest path: Have the HeatmapWidget inject HeatmapService, call getUserProjects() in getData(), and pass it through to the template via widget options/data. The widget's getTemplateName() template receives widget.data which can hold the project list.

Refactoring init() and renderHeatmap()

The current init() function fetches data and calls renderHeatmap(). For filter support, init() needs to:

  1. Parse data-projects and create the filter dropdown
  2. Store the base URL for re-fetching
  3. On filter change, re-fetch with ?project=N and re-render

Recommended refactor:

  • init() creates the wrapper layout, builds the filter, fetches initial data, and calls renderHeatmap()
  • Filter change handler re-fetches and re-calls renderHeatmap() on the SVG container area
  • Click handler is added inside renderHeatmap() since it needs access to cell data
  • A module-level or closure variable tracks the active project filter ID for URL generation

Anti-Patterns to Avoid

  • Embedding locale-specific date formats in JS: Use YYYY-MM-DD - YYYY-MM-DD only. Kimai's DateRangeType always accepts this as a fallback. [VERIFIED: fallback format in DateRangeType.php]
  • Creating a separate API endpoint for project list: Unnecessary network request. Pass via data-projects attribute at page load time.
  • Using d3.select for the dropdown: The filter is a standard HTML element, not SVG. Use plain DOM API.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Date formatting for URLs Custom date formatter d3-time-format's timeFormat('%Y-%m-%d') already defined as DATE_FORMAT Already in the codebase
Dropdown styling Custom CSS for select elements Tabler's form-select form-select-sm classes Matches Kimai's existing UI
URL encoding Manual string escaping encodeURIComponent() for daterange param The - separator needs encoding

Common Pitfalls

Pitfall 1: Daterange Format Mismatch

What goes wrong: Using a single date in the daterange parameter instead of a range, causing Kimai to show no results or the current month Why it happens: The CONTEXT.md suggests ?daterange=YYYY-MM-DD but Kimai expects begin - end format How to avoid: Always construct the URL as ?daterange=YYYY-MM-DD - YYYY-MM-DD with the same date repeated for single-day filtering Warning signs: Clicking a cell shows the wrong date range in the timesheet view

Pitfall 2: Click Handler Overriding Tooltip

What goes wrong: Adding a click handler removes or conflicts with existing mouseenter/mouseleave tooltip handlers Why it happens: If the click handler is added in a separate .selectAll().on('click') call instead of chained onto the original selection How to avoid: Add .on('click', ...) in the same selection chain as the existing mouseenter/mouseleave handlers in renderHeatmap() Warning signs: Tooltips stop working after adding click support

Pitfall 3: SVG Hover CSS on <rect> Elements

What goes wrong: CSS :hover pseudo-class on SVG <rect> elements doesn't work consistently across browsers Why it happens: SVG elements have different CSS interaction models. Some browsers need pointer-events: fill or pointer-events: visible on the element. How to avoid: Set pointer-events: fill on .heatmap-cell rects. The UI spec's cursor: pointer and opacity transitions should work with this. Warning signs: Hover effect doesn't appear on some cells or in some browsers

Pitfall 4: Re-render Without Clearing Filter State

What goes wrong: After filter re-render, click handlers on new cells don't know about the active filter Why it happens: If the active project ID is stored on cells rather than in closure/module scope How to avoid: Store active filter selection in a closure variable accessible to the click handler factory, not on individual cell data Warning signs: Clicking cells after filtering navigates without the project filter in the URL

Pitfall 5: Empty Filtered Results

What goes wrong: Filtering to a project with no time entries shows the generic "No tracking data available" message Why it happens: The empty state check in renderHeatmap() treats all empty results the same How to avoid: Pass context to renderHeatmap() or check externally -- when filtering returns empty data, show "No tracking data for this project" instead Warning signs: User filters to a project and sees a message that suggests no data exists at all

Code Examples

Click Handler Integration (in renderHeatmap)

// Source: d3-selection API + existing mouseenter/mouseleave pattern in heatmap.ts
// Add after .on('mouseleave', ...) in the cells selection chain

.on('click', function (_event: MouseEvent, d: DayCell) {
  const daterange = `${d.dateStr} - ${d.dateStr}`;
  let url = `/en/timesheet/?daterange=${encodeURIComponent(daterange)}`;
  if (activeProjectId) {
    url += `&projects[]=${activeProjectId}`;
  }
  window.location.href = url;
})

Filter Change Handler

// Source: standard DOM API + existing fetch pattern in init()
select.addEventListener('change', () => {
  const projectId = select.value;
  activeProjectId = projectId ? parseInt(projectId, 10) : null;
  const fetchUrl = projectId ? `${baseUrl}?project=${projectId}` : baseUrl;

  fetch(fetchUrl)
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json() as Promise<HeatmapData>;
    })
    .then(data => {
      renderHeatmap(svgContainer, data);
    })
    .catch(err => {
      console.error('KimaiHeatmap: failed to load filtered data', err);
    });
});

Vitest Click Navigation Test

// Source: Vitest docs + jsdom window.location mocking pattern
import { describe, it, expect, vi, beforeEach } from 'vitest';

describe('click navigation', () => {
  it('navigates to timesheet view for clicked day', () => {
    // Mock window.location.href
    const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({
      ...window.location,
      href: '',
    } as Location);

    // Or use Object.defineProperty for href assignment
    let navigatedUrl = '';
    Object.defineProperty(window, 'location', {
      value: { href: '' },
      writable: true,
    });

    renderHeatmap(container, mockData);
    const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)');
    cell!.dispatchEvent(new MouseEvent('click', { bubbles: true }));

    expect(window.location.href).toContain('/en/timesheet/');
    expect(window.location.href).toContain('daterange=');
  });
});

Widget getData() for Project List

// Source: Kimai AbstractWidget pattern + TimesheetRepository DQL
// In Widget/HeatmapWidget.php

public function __construct(private readonly HeatmapService $service)
{
}

public function getData(array $options = []): mixed
{
    $user = $options['user'] ?? null;
    if ($user === null) {
        return ['projects' => []];
    }
    return ['projects' => $this->service->getUserProjects($user)];
}

State of the Art

Old Approach Current Approach When Changed Impact
Kimai's form-based daterange URL query string with fallback yyyy-MM-dd format Stable (Kimai 2.x) Always use fallback format in programmatic URLs

Assumptions Log

# Claim Section Risk if Wrong
A1 Querying projects from timesheet table (projects user has tracked) is better UX than all visible projects Architecture Patterns / Project List Data Source Low -- fallback is to use ProjectRepository for all visible projects
A2 HeatmapWidget::getData() receives $options['user'] with the current user Code Examples / Widget getData Medium -- may need to get user from security token instead
A3 The Twig template can access widget data via widget.data or similar accessor Architecture Patterns / Twig Template Changes Medium -- need to verify how AbstractWidget passes data to templates

Open Questions (RESOLVED)

  1. How does AbstractWidget pass data to its Twig template?

    • RESOLVED: WidgetExtension::renderWidget() passes getData() result as data to the template. Plan 01 uses data.projects in Twig, which works via this mechanism.
  2. Does the /en/ prefix in the timesheet URL match all Kimai installations?

    • RESOLVED: Use data-timesheet-url="{{ path('timesheet') }}" attribute on the container div. This generates a locale-safe URL via Symfony routing. Plan 01 adopts this approach.

Validation Architecture

Test Framework

Property Value
Framework Vitest 4.1.3 + jsdom
Config file vitest.config.ts
Quick run command npm test
Full suite command npm test

Phase Requirements to Test Map

Req ID Behavior Test Type Automated Command File Exists?
HEAT-07 Click cell navigates to timesheet filtered by date unit npx vitest run --reporter=verbose No -- Wave 0
HEAT-07 Click cell with project filter includes project in URL unit npx vitest run --reporter=verbose No -- Wave 0
INTR-01 Filter dropdown renders with project options unit npx vitest run --reporter=verbose No -- Wave 0
INTR-01 Selecting project re-fetches data and re-renders unit npx vitest run --reporter=verbose No -- Wave 0
INTR-01 "All Projects" resets to unfiltered view unit npx vitest run --reporter=verbose No -- Wave 0
TEST-04 Click interaction tests exist and pass unit npx vitest run --reporter=verbose No -- Wave 0
TEST-04 Tooltip interaction tests exist and pass unit npx vitest run --reporter=verbose Partial -- existing tests cover tooltip show/hide

Sampling Rate

  • Per task commit: npm test
  • Per wave merge: npm test + manual verification in running Kimai
  • Phase gate: Full JS test suite green + PHP tests still passing

Wave 0 Gaps

  • assets/test/interaction.test.ts -- covers HEAT-07 (click navigation) and TEST-04 (click interactions)
  • assets/test/filter.test.ts -- covers INTR-01 (filter dropdown render, selection, re-fetch)
  • Mock utilities for window.location and fetch -- shared across interaction tests

Sources

Primary (HIGH confidence)

  • Kimai source code at dev/kimai/src/Controller/TimesheetController.php -- verified route path /timesheet/ and route name timesheet [VERIFIED]
  • Kimai source code at dev/kimai/src/Form/Type/DateRangeType.php -- verified daterange format, DATE_SPACER constant, and yyyy-MM-dd fallback format [VERIFIED]
  • Kimai source code at dev/kimai/tests/Controller/AbstractControllerBaseTestCase.php -- verified daterange test format using n/j/Y with - separator [VERIFIED]
  • Project source code assets/src/heatmap.ts -- verified existing rendering pattern, tooltip handlers, DayCell interface [VERIFIED]
  • Project source code Controller/HeatmapController.php -- verified ?project=N query parameter support [VERIFIED]
  • Project package.json -- verified all dependency versions [VERIFIED]

Secondary (MEDIUM confidence)

  • None

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- no new dependencies, all existing code verified
  • Architecture: HIGH -- patterns verified against Kimai source and existing codebase
  • Pitfalls: HIGH -- daterange format verified against actual Kimai code, not assumed
  • Interaction patterns: HIGH -- d3 click handling is standard API, verified in d3-selection docs

Research date: 2026-04-08 Valid until: 2026-05-08 (stable -- no fast-moving dependencies)