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.hrefto 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-- theYYYY-MM-DDdate string (already inDayCellinterface)- 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:
- Parse
data-projectsand create the filter dropdown - Store the base URL for re-fetching
- On filter change, re-fetch with
?project=Nand re-render
Recommended refactor:
init()creates the wrapper layout, builds the filter, fetches initial data, and callsrenderHeatmap()- Filter
changehandler re-fetches and re-callsrenderHeatmap()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-DDonly. 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-projectsattribute at page load time. - Using
d3.selectfor 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)
-
How does AbstractWidget pass data to its Twig template?
- RESOLVED:
WidgetExtension::renderWidget()passesgetData()result asdatato the template. Plan 01 usesdata.projectsin Twig, which works via this mechanism.
- RESOLVED:
-
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.
- RESOLVED: Use
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.locationandfetch-- shared across interaction tests
Sources
Primary (HIGH confidence)
- Kimai source code at
dev/kimai/src/Controller/TimesheetController.php-- verified route path/timesheet/and route nametimesheet[VERIFIED] - Kimai source code at
dev/kimai/src/Form/Type/DateRangeType.php-- verified daterange format,DATE_SPACERconstant, andyyyy-MM-ddfallback format [VERIFIED] - Kimai source code at
dev/kimai/tests/Controller/AbstractControllerBaseTestCase.php-- verified daterange test format usingn/j/Ywith-separator [VERIFIED] - Project source code
assets/src/heatmap.ts-- verified existing rendering pattern, tooltip handlers,DayCellinterface [VERIFIED] - Project source code
Controller/HeatmapController.php-- verified?project=Nquery 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)