kimai-plugin-heatmap/.planning/phases/04-heatmap-interaction/04-01-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

20 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
04-heatmap-interaction 01 execute 1
Service/HeatmapService.php
Widget/HeatmapWidget.php
Resources/views/widget/heatmap.html.twig
Resources/public/heatmap.css
assets/src/heatmap.ts
assets/src/types.ts
Resources/public/heatmap.js
true
HEAT-07
INTR-01
truths artifacts key_links
Clicking any heatmap day cell (with or without data) navigates to Kimai timesheet filtered to that date
A project filter dropdown appears to the right of the heatmap SVG
Selecting a project re-fetches data and re-renders the heatmap without page reload
Selecting 'All Projects' resets to unfiltered data
When a project filter is active, click navigation URL includes the project parameter
Cells show pointer cursor and opacity hover effect as click affordance
path provides contains
assets/src/heatmap.ts Click handler on cells, filter dropdown creation, re-fetch on filter change window.location.href
path provides contains
Service/HeatmapService.php getUserProjects() method returning projects the user has tracked time for getUserProjects
path provides contains
Widget/HeatmapWidget.php getData() returns project list for template getUserProjects
path provides contains
Resources/views/widget/heatmap.html.twig data-projects and data-timesheet-url attributes on container data-projects
path provides contains
Resources/public/heatmap.css Click affordance styles, flex layout for heatmap + filter heatmap-wrapper
from to via pattern
assets/src/heatmap.ts Kimai timesheet window.location.href with daterange param window.location.href
from to via pattern
assets/src/heatmap.ts HeatmapController::data() fetch with ?project=N query param fetch.*project=
from to via pattern
Widget/HeatmapWidget.php HeatmapService::getUserProjects() getData() calls service method getUserProjects
Add 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 in place.

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

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

  1. data-projects — JSON-encoded project list from data.projects
  2. data-timesheet-url — Kimai timesheet base path via path('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():

  1. Add click handler to the existing cell selection chain (after .on('mouseleave', ...) line). The handler constructs the Kimai timesheet URL using the daterange format YYYY-MM-DD - YYYY-MM-DD (same date repeated). It reads timesheetUrl from closure and appends &projects[]=N when 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);
})
  1. Add onCellClick parameter to renderHeatmap() signature:
export function renderHeatmap(
  container: HTMLElement,
  data: HeatmapData,
  config: HeatmapConfig = DEFAULT_CONFIG,
  onCellClick?: (dateStr: string) => void,
): void
  1. Add empty state for filtered results. When data.days is empty, check if a filteredEmpty context flag is set. Add an optional emptyMessage parameter to renderHeatmap:
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.

  1. Refactor init() to:
    • Parse data-projects JSON attribute into ProjectOption[]
    • Parse data-timesheet-url attribute for the timesheet base path
    • Track activeProjectId: number | null = null in closure scope
    • Create a flex wrapper div with class heatmap-wrapper
    • Create a heatmap-svg-area div for the SVG
    • Create a heatmap-filter div with a <select> element (class form-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 onCellClick callback that constructs {timesheetUrl}?daterange={encodeURIComponent(date + ' - ' + date)} and appends &projects[]={activeProjectId} when filter is active, then assigns to window.location.href
    • On initial fetch success, call renderHeatmap(svgArea, data, DEFAULT_CONFIG, onCellClick)
    • On select change event: update activeProjectId, re-fetch from {baseUrl}?project={id} (or just baseUrl for "All Projects"), call renderHeatmap(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

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';
    });
}
  1. Import ProjectOption at 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.

  1. Rebuild JS bundle: Run npx esbuild assets/src/heatmap.ts --bundle --format=iife --global-name=KimaiHeatmap --outfile=Resources/public/heatmap.js --platform=browser to 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 onCellClick parameter in renderHeatmap signature
    • assets/src/heatmap.ts contains activeProjectId variable
    • assets/src/heatmap.ts contains form-select form-select-sm (Tabler dropdown class)
    • assets/src/heatmap.ts contains aria-label with value Filter by project
    • assets/src/heatmap.ts contains All Projects default option text
    • assets/src/heatmap.ts contains No tracking data for this project empty state for filtered results
    • assets/src/heatmap.ts contains data-timesheet-url attribute 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-wrapper flex layout rule
    • Resources/public/heatmap.css contains .heatmap-cell with cursor: pointer
    • Resources/public/heatmap.css contains .heatmap-cell:hover with opacity: 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

<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>
1. `grep 'getUserProjects' Service/HeatmapService.php` returns match 2. `grep 'data-projects' Resources/views/widget/heatmap.html.twig` returns match 3. `grep 'window.location.href' assets/src/heatmap.ts` returns match 4. `grep 'heatmap-wrapper' Resources/public/heatmap.css` returns match 5. esbuild builds without errors 6. Existing tests still pass: `npx vitest run`

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