docs(04): create phase plans for heatmap interaction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41b7c8ec93
commit
fc4b96c10f
3 changed files with 1037 additions and 7 deletions
|
|
@ -14,7 +14,7 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||
|
||||
- [x] **Phase 1: Dev Environment** - Nix flake with local Kimai instance and seeded test data
|
||||
- [x] **Phase 2: Plugin Scaffold + Data Layer** - Symfony bundle, dashboard widget, aggregation API with PHPUnit tests
|
||||
- [ ] **Phase 3: Core Heatmap Rendering** - d3.js calendar grid with color mapping, labels, tooltips, theme integration, and JS tests
|
||||
- [x] **Phase 3: Core Heatmap Rendering** - d3.js calendar grid with color mapping, labels, tooltips, theme integration, and JS tests
|
||||
- [ ] **Phase 4: Heatmap Interaction** - Click-through navigation, project/activity filtering, interaction tests
|
||||
- [ ] **Phase 5: Polish** - Streak indicator, summary stats, weekend styling
|
||||
|
||||
|
|
@ -65,8 +65,8 @@ Plans:
|
|||
|
||||
Plans:
|
||||
- [x] 03-01: JS toolchain — npm, esbuild, TypeScript, Vitest
|
||||
- [ ] 03-02: d3 calendar heatmap with color scale, labels, tooltips + Vitest tests
|
||||
- [ ] 03-03: Twig template integration + asset serving
|
||||
- [x] 03-02: d3 calendar heatmap with color scale, labels, tooltips + Vitest tests
|
||||
- [x] 03-03: Twig template integration + asset serving
|
||||
|
||||
### Phase 4: Heatmap Interaction
|
||||
**Goal**: Users can click through to daily details and filter the heatmap by project or activity
|
||||
|
|
@ -77,12 +77,12 @@ Plans:
|
|||
2. A dropdown allows filtering the heatmap to show data for a single project or activity
|
||||
3. Filtering updates the heatmap in place without a full page reload
|
||||
4. JavaScript tests verify click navigation and tooltip interaction behavior
|
||||
**Plans**: TBD
|
||||
**Plans:** 2 plans
|
||||
**UI hint**: yes
|
||||
|
||||
Plans:
|
||||
- [ ] 04-01: TBD
|
||||
- [ ] 04-02: TBD
|
||||
- [ ] 04-01: Day cell click navigation + project filter dropdown
|
||||
- [ ] 04-02: Vitest tests for click navigation and filter behavior
|
||||
|
||||
### Phase 5: Polish
|
||||
**Goal**: The widget provides at-a-glance context beyond the heatmap itself -- streaks, stats, and visual cues for weekends
|
||||
|
|
@ -108,6 +108,6 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5
|
|||
|-------|----------------|--------|-----------|
|
||||
| 1. Dev Environment | 2/2 | Done | 2026-04-08 |
|
||||
| 2. Plugin Scaffold + Data Layer | 2/2 | Done | 2026-04-08 |
|
||||
| 3. Core Heatmap Rendering | 1/3 | In progress | - |
|
||||
| 3. Core Heatmap Rendering | 3/3 | Done | 2026-04-08 |
|
||||
| 4. Heatmap Interaction | 0/2 | Not started | - |
|
||||
| 5. Polish | 0/2 | Not started | - |
|
||||
|
|
|
|||
524
.planning/phases/04-heatmap-interaction/04-01-PLAN.md
Normal file
524
.planning/phases/04-heatmap-interaction/04-01-PLAN.md
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
---
|
||||
phase: 04-heatmap-interaction
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- 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
|
||||
autonomous: true
|
||||
requirements: [HEAT-07, INTR-01]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: "assets/src/heatmap.ts"
|
||||
provides: "Click handler on cells, filter dropdown creation, re-fetch on filter change"
|
||||
contains: "window.location.href"
|
||||
- path: "Service/HeatmapService.php"
|
||||
provides: "getUserProjects() method returning projects the user has tracked time for"
|
||||
contains: "getUserProjects"
|
||||
- path: "Widget/HeatmapWidget.php"
|
||||
provides: "getData() returns project list for template"
|
||||
contains: "getUserProjects"
|
||||
- path: "Resources/views/widget/heatmap.html.twig"
|
||||
provides: "data-projects and data-timesheet-url attributes on container"
|
||||
contains: "data-projects"
|
||||
- path: "Resources/public/heatmap.css"
|
||||
provides: "Click affordance styles, flex layout for heatmap + filter"
|
||||
contains: "heatmap-wrapper"
|
||||
key_links:
|
||||
- from: "assets/src/heatmap.ts"
|
||||
to: "Kimai timesheet"
|
||||
via: "window.location.href with daterange param"
|
||||
pattern: "window\\.location\\.href"
|
||||
- from: "assets/src/heatmap.ts"
|
||||
to: "HeatmapController::data()"
|
||||
via: "fetch with ?project=N query param"
|
||||
pattern: "fetch.*project="
|
||||
- from: "Widget/HeatmapWidget.php"
|
||||
to: "HeatmapService::getUserProjects()"
|
||||
via: "getData() calls service method"
|
||||
pattern: "getUserProjects"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From assets/src/types.ts:
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
interface DayCell {
|
||||
date: Date;
|
||||
dateStr: string;
|
||||
entry: DayEntry | null;
|
||||
week: number;
|
||||
day: number;
|
||||
}
|
||||
```
|
||||
|
||||
From Widget/HeatmapWidget.php:
|
||||
```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):
|
||||
```php
|
||||
return $environment->render($widget->getTemplateName(), [
|
||||
'data' => $widget->getData($options),
|
||||
'options' => $options,
|
||||
'title' => $widget->getTitle(),
|
||||
'widget' => $widget,
|
||||
]);
|
||||
```
|
||||
|
||||
From Controller/HeatmapController.php:
|
||||
```php
|
||||
// Already accepts ?project=N query parameter
|
||||
$projectId = $request->query->getInt('project') ?: null;
|
||||
$days = $service->getDailyAggregation($user, $begin, $end, $projectId);
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Backend — getUserProjects service method, widget getData, Twig data attributes</name>
|
||||
<files>Service/HeatmapService.php, Widget/HeatmapWidget.php, Resources/views/widget/heatmap.html.twig</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
**HeatmapService.php** — Add `getUserProjects(User $user): array` method that queries distinct projects the user has tracked time for:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @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.
|
||||
|
||||
```php
|
||||
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:
|
||||
```twig
|
||||
{% 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 %}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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') }}"`
|
||||
</acceptance_criteria>
|
||||
<done>HeatmapService has getUserProjects method, HeatmapWidget injects service and returns project list in getData, Twig template passes projects and timesheet URL as data attributes</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Frontend — Click navigation, filter dropdown, CSS, esbuild rebuild</name>
|
||||
<files>assets/src/heatmap.ts, assets/src/types.ts, Resources/public/heatmap.css, Resources/public/heatmap.js</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
**assets/src/types.ts** — Add a `ProjectOption` interface:
|
||||
|
||||
```typescript
|
||||
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', ...)`:
|
||||
```typescript
|
||||
.on('click', function (_event: MouseEvent, d: DayCell) {
|
||||
if (!onCellClick) return;
|
||||
onCellClick(d.dateStr);
|
||||
})
|
||||
```
|
||||
|
||||
2. **Add `onCellClick` parameter** to `renderHeatmap()` signature:
|
||||
```typescript
|
||||
export function renderHeatmap(
|
||||
container: HTMLElement,
|
||||
data: HeatmapData,
|
||||
config: HeatmapConfig = DEFAULT_CONFIG,
|
||||
onCellClick?: (dateStr: string) => void,
|
||||
): void
|
||||
```
|
||||
|
||||
3. **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`:
|
||||
```typescript
|
||||
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.
|
||||
|
||||
4. **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:
|
||||
```typescript
|
||||
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';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
5. **Import `ProjectOption`** at top of heatmap.ts:
|
||||
```typescript
|
||||
import type { DayEntry, HeatmapData, HeatmapConfig, ProjectOption } from './types';
|
||||
```
|
||||
|
||||
**Resources/public/heatmap.css** — Add click affordance and layout rules after existing styles:
|
||||
|
||||
```css
|
||||
/* 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`.
|
||||
|
||||
6. **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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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`
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-heatmap-interaction/04-01-SUMMARY.md`
|
||||
</output>
|
||||
506
.planning/phases/04-heatmap-interaction/04-02-PLAN.md
Normal file
506
.planning/phases/04-heatmap-interaction/04-02-PLAN.md
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
---
|
||||
phase: 04-heatmap-interaction
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [04-01]
|
||||
files_modified:
|
||||
- assets/test/interaction.test.ts
|
||||
- assets/test/filter.test.ts
|
||||
autonomous: true
|
||||
requirements: [TEST-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: "assets/test/interaction.test.ts"
|
||||
provides: "Click navigation tests for HEAT-07 and TEST-04"
|
||||
contains: "window.location.href"
|
||||
- path: "assets/test/filter.test.ts"
|
||||
provides: "Filter dropdown tests for INTR-01 and TEST-04"
|
||||
contains: "form-select"
|
||||
key_links:
|
||||
- from: "assets/test/interaction.test.ts"
|
||||
to: "assets/src/heatmap.ts"
|
||||
via: "imports renderHeatmap and init"
|
||||
pattern: "import.*heatmap"
|
||||
- from: "assets/test/filter.test.ts"
|
||||
to: "assets/src/heatmap.ts"
|
||||
via: "imports init"
|
||||
pattern: "import.*heatmap"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from Plan 01 implementation -->
|
||||
|
||||
From assets/src/heatmap.ts (after Plan 01):
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
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):
|
||||
```typescript
|
||||
// Uses makeMockData() helper returning HeatmapData
|
||||
// Uses document.createElement('div') for container
|
||||
// Uses container.querySelector() for DOM assertions
|
||||
// Dispatches MouseEvent for interaction testing
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Click navigation tests</name>
|
||||
<files>assets/test/interaction.test.ts</files>
|
||||
<read_first>
|
||||
- assets/src/heatmap.ts
|
||||
- assets/src/types.ts
|
||||
- assets/test/heatmap.test.ts
|
||||
- .planning/phases/04-heatmap-interaction/04-RESEARCH.md
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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/`
|
||||
</behavior>
|
||||
<action>
|
||||
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:**
|
||||
```typescript
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1</automated>
|
||||
</verify>
|
||||
<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>
|
||||
<done>Click navigation tests pass: verify cell click triggers onCellClick callback, init() navigates to correct timesheet URL, project filter is included when active</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Filter dropdown tests</name>
|
||||
<files>assets/test/filter.test.ts</files>
|
||||
<read_first>
|
||||
- assets/src/heatmap.ts
|
||||
- assets/src/types.ts
|
||||
- assets/test/interaction.test.ts
|
||||
- assets/test/heatmap.test.ts
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
Create `assets/test/filter.test.ts` with project filter dropdown tests.
|
||||
|
||||
**Mock strategy:** Same as interaction tests — mock `global.fetch` and verify call arguments.
|
||||
|
||||
```typescript
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/toph/code/toph/kimai-heatmap && npx vitest run --reporter=verbose 2>&1</automated>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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)
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-heatmap-interaction/04-02-SUMMARY.md`
|
||||
</output>
|
||||
Loading…
Add table
Reference in a new issue