20 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-heatmap-interaction | 01 | execute | 1 |
|
true |
|
|
Purpose: Users can drill into specific days and filter their heatmap by project, making the widget actionable rather than just informational. Output: Working click navigation, project filter dropdown, updated CSS and templates, rebuilt JS bundle.
<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/STATE.md @.planning/phases/04-heatmap-interaction/04-CONTEXT.md @.planning/phases/04-heatmap-interaction/04-RESEARCH.md @.planning/phases/04-heatmap-interaction/04-UI-SPEC.mdFrom assets/src/types.ts:
export interface DayEntry {
date: string; // "YYYY-MM-DD"
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;
}
From assets/src/heatmap.ts (internal DayCell interface, line 22):
interface DayCell {
date: Date;
dateStr: string;
entry: DayEntry | null;
week: number;
day: number;
}
From Widget/HeatmapWidget.php:
final class HeatmapWidget extends AbstractWidget
// AbstractWidget provides: getUser(): User, setUser(User): void
// WidgetExtension calls setUser() before getData()
// Template receives: data (from getData), options, title, widget
From Twig/Runtime/WidgetExtension.php (line 53):
return $environment->render($widget->getTemplateName(), [
'data' => $widget->getData($options),
'options' => $options,
'title' => $widget->getTitle(),
'widget' => $widget,
]);
From Controller/HeatmapController.php:
// Already accepts ?project=N query parameter
$projectId = $request->query->getInt('project') ?: null;
$days = $service->getDailyAggregation($user, $begin, $end, $projectId);
Task 1: Backend — getUserProjects service method, widget getData, Twig data attributes
Service/HeatmapService.php, Widget/HeatmapWidget.php, Resources/views/widget/heatmap.html.twig
- Service/HeatmapService.php
- Widget/HeatmapWidget.php
- Resources/views/widget/heatmap.html.twig
- dev/kimai/src/Widget/Type/AbstractWidget.php
- dev/kimai/src/Twig/Runtime/WidgetExtension.php
**HeatmapService.php** — Add `getUserProjects(User $user): array` method that queries distinct projects the user has tracked time for:
/**
* @return array<int, array{id: int, name: string}>
*/
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());
}
HeatmapWidget.php — Remove final keyword (needed for testing). Inject HeatmapService via constructor. Update getData() to return project list. The getUser() method is inherited from AbstractWidget and is set by WidgetExtension::renderWidget() before getData() is called.
use KimaiPlugin\KimaiHeatmapBundle\Service\HeatmapService;
class HeatmapWidget extends AbstractWidget
{
public function __construct(private readonly HeatmapService $service)
{
}
public function getData(array $options = []): mixed
{
$user = $this->getUser();
return [
'projects' => $this->service->getUserProjects($user),
];
}
// ... rest unchanged
}
heatmap.html.twig — Add two new data- attributes to the container div:
data-projects— JSON-encoded project list fromdata.projectsdata-timesheet-url— Kimai timesheet base path viapath('timesheet')(locale-safe, avoids hardcoding/en/timesheet/)
Updated template:
{% embed '@theme/embeds/card.html.twig' with {'margin_bottom': 0} %}
{% block box_title %}
{{ title }}
{% endblock %}
{% block box_body %}
<link rel="stylesheet" href="{{ asset('bundles/kimaiheatmap/heatmap.css') }}">
<div id="heatmap-container"
data-url="{{ path('heatmap_data') }}"
data-projects="{{ data.projects|json_encode }}"
data-timesheet-url="{{ path('timesheet') }}"
style="min-height: 150px; overflow-x: auto;">
</div>
<script src="{{ asset('bundles/kimaiheatmap/heatmap.js') }}"></script>
<script type="text/javascript">
var initHeatmap = function() {
KimaiHeatmap.init(document.getElementById('heatmap-container'));
};
{% if kimai_context is defined and kimai_context.javascriptRequest %}
initHeatmap();
{% else %}
document.addEventListener('kimai.initialized', initHeatmap);
{% endif %}
</script>
{% endblock %}
{% endembed %}
cd /home/toph/code/toph/kimai-heatmap && grep -c 'getUserProjects' Service/HeatmapService.php && grep -c 'getUserProjects' Widget/HeatmapWidget.php && grep -c 'data-projects' Resources/views/widget/heatmap.html.twig && grep -c 'data-timesheet-url' Resources/views/widget/heatmap.html.twig
- Service/HeatmapService.php contains `public function getUserProjects(User $user): array`
- Service/HeatmapService.php contains `DISTINCT IDENTITY(t.project)`
- Widget/HeatmapWidget.php contains `private readonly HeatmapService $service`
- Widget/HeatmapWidget.php contains `$this->service->getUserProjects`
- Widget/HeatmapWidget.php does NOT have `final class` (just `class`)
- Resources/views/widget/heatmap.html.twig contains `data-projects="{{ data.projects|json_encode }}"`
- Resources/views/widget/heatmap.html.twig contains `data-timesheet-url="{{ path('timesheet') }}"`
HeatmapService has getUserProjects method, HeatmapWidget injects service and returns project list in getData, Twig template passes projects and timesheet URL as data attributes
Task 2: Frontend — Click navigation, filter dropdown, CSS, esbuild rebuild
assets/src/heatmap.ts, assets/src/types.ts, Resources/public/heatmap.css, Resources/public/heatmap.js
- assets/src/heatmap.ts
- assets/src/types.ts
- Resources/public/heatmap.css
- .planning/phases/04-heatmap-interaction/04-UI-SPEC.md
- .planning/phases/04-heatmap-interaction/04-RESEARCH.md
**assets/src/types.ts** — Add a `ProjectOption` interface:
export interface ProjectOption {
id: number;
name: string;
}
assets/src/heatmap.ts — Major refactoring of init() and additions to renderHeatmap():
- Add click handler to the existing cell selection chain (after
.on('mouseleave', ...)line). The handler constructs the Kimai timesheet URL using thedaterangeformatYYYY-MM-DD - YYYY-MM-DD(same date repeated). It readstimesheetUrlfrom closure and appends&projects[]=Nwhen a project filter is active.
Add .on('click', ...) to the cells selection chain at line ~193, right after the .on('mouseleave', ...):
.on('click', function (_event: MouseEvent, d: DayCell) {
if (!onCellClick) return;
onCellClick(d.dateStr);
})
- Add
onCellClickparameter torenderHeatmap()signature:
export function renderHeatmap(
container: HTMLElement,
data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG,
onCellClick?: (dateStr: string) => void,
): void
- Add empty state for filtered results. When
data.daysis empty, check if afilteredEmptycontext flag is set. Add an optionalemptyMessageparameter torenderHeatmap:
export function renderHeatmap(
container: HTMLElement,
data: HeatmapData,
config: HeatmapConfig = DEFAULT_CONFIG,
onCellClick?: (dateStr: string) => void,
emptyMessage?: string,
): void
Use emptyMessage || 'No tracking data available' for the empty state text.
- Refactor
init()to:- Parse
data-projectsJSON attribute intoProjectOption[] - Parse
data-timesheet-urlattribute for the timesheet base path - Track
activeProjectId: number | null = nullin closure scope - Create a flex wrapper div with class
heatmap-wrapper - Create a
heatmap-svg-areadiv for the SVG - Create a
heatmap-filterdiv with a<select>element (classform-select form-select-sm,aria-label="Filter by project") - Populate select with "All Projects" default (value
"") + one option per project (value=id, text=name) - Define
onCellClickcallback that constructs{timesheetUrl}?daterange={encodeURIComponent(date + ' - ' + date)}and appends&projects[]={activeProjectId}when filter is active, then assigns towindow.location.href - On initial fetch success, call
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick) - On select
changeevent: updateactiveProjectId, re-fetch from{baseUrl}?project={id}(or justbaseUrlfor "All Projects"), callrenderHeatmap(svgArea, newData, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project')on success - Only create the filter dropdown if
projects.length > 0(skip dropdown when no projects available) - On fetch error, keep existing heatmap visible and log to console
- Parse
Full init() rewrite:
export function init(container: HTMLElement): void {
const baseUrl = container.getAttribute('data-url');
if (!baseUrl) {
console.error('KimaiHeatmap: missing data-url attribute');
return;
}
const timesheetUrl = container.getAttribute('data-timesheet-url') || '/en/timesheet/';
const projectsJson = container.getAttribute('data-projects');
const projects: ProjectOption[] = projectsJson ? JSON.parse(projectsJson) : [];
let activeProjectId: number | null = null;
const onCellClick = (dateStr: string): void => {
const daterange = `${dateStr} - ${dateStr}`;
let url = `${timesheetUrl}?daterange=${encodeURIComponent(daterange)}`;
if (activeProjectId) {
url += `&projects[]=${activeProjectId}`;
}
window.location.href = url;
};
// Build wrapper layout
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'heatmap-wrapper';
const svgArea = document.createElement('div');
svgArea.className = 'heatmap-svg-area';
wrapper.appendChild(svgArea);
// Build filter dropdown (only if projects exist)
if (projects.length > 0) {
const filterDiv = document.createElement('div');
filterDiv.className = 'heatmap-filter';
const select = document.createElement('select');
select.className = 'form-select form-select-sm';
select.setAttribute('aria-label', 'Filter by project');
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'All Projects';
select.appendChild(defaultOpt);
for (const p of projects) {
const opt = document.createElement('option');
opt.value = String(p.id);
opt.textContent = p.name;
select.appendChild(opt);
}
select.addEventListener('change', () => {
const val = select.value;
activeProjectId = val ? parseInt(val, 10) : null;
const fetchUrl = val ? `${baseUrl}?project=${val}` : baseUrl;
fetch(fetchUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<HeatmapData>;
})
.then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick, 'No tracking data for this project');
})
.catch(err => {
console.error('KimaiHeatmap: failed to load filtered data', err);
});
});
filterDiv.appendChild(select);
wrapper.appendChild(filterDiv);
}
container.appendChild(wrapper);
// Initial data fetch
fetch(baseUrl)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<HeatmapData>;
})
.then(data => {
renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick);
})
.catch(err => {
console.error('KimaiHeatmap: failed to load data', err);
svgArea.textContent = 'Failed to load heatmap data';
});
}
- Import
ProjectOptionat top of heatmap.ts:
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
Resources/public/heatmap.css — Add click affordance and layout rules after existing styles:
/* Click affordance */
.heatmap-cell {
cursor: pointer;
transition: opacity 0.1s ease;
pointer-events: fill;
}
.heatmap-cell:hover {
opacity: 0.75;
}
/* Layout: heatmap + filter side by side */
.heatmap-wrapper {
display: flex;
align-items: flex-start;
gap: 16px;
}
.heatmap-wrapper .heatmap-svg-area {
flex: 1;
min-width: 0;
overflow-x: auto;
}
.heatmap-wrapper .heatmap-filter {
flex-shrink: 0;
padding-top: 20px;
}
.heatmap-filter select {
min-width: 140px;
max-width: 200px;
}
Note: the existing .heatmap-cell rule (rx/ry) should be merged with the new properties. Keep rx: 2; ry: 2; and add cursor, transition, pointer-events.
- Rebuild JS bundle: Run
npx esbuild assets/src/heatmap.ts --bundle --format=iife --global-name=KimaiHeatmap --outfile=Resources/public/heatmap.js --platform=browserto produce the updated bundle. cd /home/toph/code/toph/kimai-heatmap && npx esbuild assets/src/heatmap.ts --bundle --format=iife --global-name=KimaiHeatmap --outfile=Resources/public/heatmap.js --platform=browser 2>&1 && grep -c 'window.location.href' assets/src/heatmap.ts && grep -c 'heatmap-wrapper' Resources/public/heatmap.css && grep -c 'form-select' assets/src/heatmap.ts <acceptance_criteria>- assets/src/heatmap.ts contains
window.location.href(click navigation) - assets/src/heatmap.ts contains
onCellClickparameter in renderHeatmap signature - assets/src/heatmap.ts contains
activeProjectIdvariable - assets/src/heatmap.ts contains
form-select form-select-sm(Tabler dropdown class) - assets/src/heatmap.ts contains
aria-labelwith valueFilter by project - assets/src/heatmap.ts contains
All Projectsdefault option text - assets/src/heatmap.ts contains
No tracking data for this projectempty state for filtered results - assets/src/heatmap.ts contains
data-timesheet-urlattribute read - assets/src/heatmap.ts contains
dateStr} - ${dateStr}range format for daterange param - assets/src/heatmap.ts contains
&projects[]=for project filter in click URL - assets/src/heatmap.ts contains
?project=for API filter param - assets/src/types.ts contains
export interface ProjectOption - Resources/public/heatmap.css contains
.heatmap-wrapperflex layout rule - Resources/public/heatmap.css contains
.heatmap-cellwithcursor: pointer - Resources/public/heatmap.css contains
.heatmap-cell:hoverwithopacity: 0.75 - Resources/public/heatmap.css contains
pointer-events: fill - Resources/public/heatmap.js exists and contains
KimaiHeatmap - esbuild completes without errors </acceptance_criteria> Heatmap cells are clickable and navigate to Kimai timesheet filtered by date, project filter dropdown renders and re-fetches/re-renders heatmap, CSS provides hover affordance and flex layout, JS bundle rebuilt
- assets/src/heatmap.ts contains
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| JS -> Kimai API | Fetch call to /heatmap/data with user-controlled ?project=N param |
| JS -> browser navigation | window.location.href assignment with constructed URL |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-01 | Tampering | data-projects attribute | accept | Data is rendered server-side by Twig from trusted DB query; client-side JSON.parse of server-provided data is safe |
| T-04-02 | Information Disclosure | /heatmap/data?project=N | accept | Controller already checks auth and user ownership via HeatmapService query filtering by user |
| T-04-03 | Spoofing | URL construction for navigation | mitigate | Use encodeURIComponent for daterange param; project ID is integer-only from select value |
| </threat_model> |
<success_criteria>
- Clicking any heatmap cell constructs a Kimai timesheet URL with daterange param and navigates via window.location.href
- Project filter dropdown renders with Tabler form-select classes
- Selecting a project re-fetches data and re-renders heatmap
- CSS provides cursor:pointer and opacity hover effect on all cells
- Flex layout positions filter dropdown to the right of heatmap SVG
- JS bundle rebuilt successfully
- Existing Vitest tests still pass </success_criteria>