docs(08): create phase plan

This commit is contained in:
Christopher Mühl 2026-04-09 21:12:34 +02:00
parent 449366eb51
commit 09112efd7e
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
3 changed files with 649 additions and 2 deletions

View file

@ -68,7 +68,10 @@ Plans:
3. Customer filter param narrows heatmap data to all projects under a selected customer
4. Custom cascade endpoints (/heatmap/customers, /heatmap/projects, /heatmap/activities) return entity lists using session auth (not API auth)
5. PHPUnit tests cover hourly aggregation, day/hour aggregation, and filter parameter handling
**Plans**: TBD
**Plans:** 2 plans
Plans:
- [ ] 08-01-PLAN.md — Service layer: aggregation methods, cascade queries, filter support
- [ ] 08-02-PLAN.md — Controller: mode dispatch, cascade endpoints, TS types
### Phase 9: Day + Combined Modes
**Goal**: Users can view time-of-day and day/hour punchcard visualizations with a color scale legend
@ -110,6 +113,6 @@ Note: Phases 7 and 8 can execute in parallel (both depend only on Phase 6).
| 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 |
| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - |
| 7. Mode Switcher + Week Mode | v1.1 | 0/2 | Planned | - |
| 8. Backend Aggregation + Filtering | v1.1 | 0/? | Not started | - |
| 8. Backend Aggregation + Filtering | v1.1 | 0/2 | Not started | - |
| 9. Day + Combined Modes | v1.1 | 0/? | Not started | - |
| 10. Entity Pickers | v1.1 | 0/? | Not started | - |

View file

@ -0,0 +1,284 @@
---
phase: 08-backend-aggregation-filtering
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- Service/HeatmapService.php
- Tests/Service/HeatmapServiceTest.php
autonomous: true
requirements: [API-01, FILT-02, FILT-03, TEST-03]
must_haves:
truths:
- "getHourlyAggregation returns hour-of-day aggregation with timezone-correct hour extraction"
- "getDayHourAggregation returns 7x24 day/hour matrix with weekStart-relative day index"
- "Activity filter narrows all aggregation queries to a specific activity"
- "Customer filter narrows all aggregation queries via project->customer join"
- "getDailyAggregation accepts optional activity and customer filter params"
artifacts:
- path: "Service/HeatmapService.php"
provides: "Hourly, day/hour aggregation + filter params on all queries"
exports: ["getHourlyAggregation", "getDayHourAggregation", "getUserCustomers", "getUserActivities"]
- path: "Tests/Service/HeatmapServiceTest.php"
provides: "Unit tests for all new service methods"
key_links:
- from: "Service/HeatmapService.php"
to: "DBAL Connection"
via: "repository->getEntityManager()->getConnection()"
pattern: "executeQuery.*CONVERT_TZ"
- from: "Service/HeatmapService.php"
to: "TimesheetRepository QueryBuilder"
via: "createQueryBuilder for DQL queries"
pattern: "createQueryBuilder.*join.*customer"
---
<objective>
Extend HeatmapService with hourly and day/hour aggregation methods, cascade entity queries, and activity/customer filter support on all aggregation methods.
Purpose: Backend data layer for Phase 9 renderers (day-mode, combined-mode) and Phase 10 entity pickers. All new queries use parameterized SQL to prevent injection.
Output: Extended HeatmapService with 4 new public methods + filter params on existing method, fully tested.
</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/08-backend-aggregation-filtering/08-CONTEXT.md
@.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
<interfaces>
<!-- Existing service interface that will be extended -->
From Service/HeatmapService.php:
```php
class HeatmapService
{
public function __construct(private readonly TimesheetRepository $repository) {}
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null): array {}
public function getUserProjects(User $user): array {}
}
```
From Tests/Service/HeatmapServiceTest.php:
```php
// Mock helper creates: AbstractQuery mock, QueryBuilder mock (with select/addSelect/andWhere/setParameter/groupBy/orderBy/expr/getQuery stubs), TimesheetRepository mock
private function createServiceWithResults(array $results): HeatmapService {}
```
From 08-RESEARCH.md — Native SQL pattern:
```php
$conn = $this->repository->getEntityManager()->getConnection();
$result = $conn->executeQuery($sql, $params);
$rows = $result->fetchAllAssociative();
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add aggregation methods and filter support to HeatmapService</name>
<files>Service/HeatmapService.php, Tests/Service/HeatmapServiceTest.php</files>
<read_first>
- Service/HeatmapService.php (current service with getDailyAggregation and getUserProjects)
- Tests/Service/HeatmapServiceTest.php (existing test pattern and createServiceWithResults helper)
- .planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md (native SQL patterns, timezone handling, mock patterns)
</read_first>
<behavior>
- Test: getHourlyAggregation returns array of {hour: int (0-23), hours: float, count: int} from mocked native SQL results
- Test: getHourlyAggregation converts duration seconds to hours rounded to 2 decimals (e.g., 7200 -> 2.0)
- Test: getHourlyAggregation returns empty array when no data
- Test: getDayHourAggregation returns array of {day: int (0-6), hour: int (0-23), hours: float, count: int}
- Test: getDayHourAggregation remaps MySQL DAYOFWEEK (1=Sun..7=Sat) to 0-6 relative to weekStart='monday' (Mon=0, Tue=1, ..., Sun=6)
- Test: getDayHourAggregation remaps correctly for weekStart='sunday' (Sun=0, Mon=1, ..., Sat=6)
- Test: getDailyAggregation with activityId param calls andWhere with activity filter
- Test: getDailyAggregation with customerId param calls join('t.project','p') and andWhere with customer filter
- Test: getUserCustomers returns [{id: int, name: string}] from user's timesheet customers
- Test: getUserActivities returns [{id: int, name: string}], optionally scoped by projectId
</behavior>
<action>
**Write tests first (RED), then implement (GREEN).**
**Test additions to HeatmapServiceTest.php:**
Add a `createServiceWithNativeResults(array $results)` helper that mocks the DBAL Connection chain:
```php
private function createServiceWithNativeResults(array $results): HeatmapService
{
$statement = $this->createMock(\Doctrine\DBAL\Result::class);
$statement->method('fetchAllAssociative')->willReturn($results);
$connection = $this->createMock(\Doctrine\DBAL\Connection::class);
$connection->method('executeQuery')->willReturn($statement);
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
$em->method('getConnection')->willReturn($connection);
$repo = $this->createMock(TimesheetRepository::class);
$repo->method('getEntityManager')->willReturn($em);
return new HeatmapService($repo);
}
```
Update the existing `createServiceWithResults()` to also stub `join()` on the QueryBuilder mock:
```php
$qb->method('join')->willReturnSelf();
```
Add test methods:
- `testGetHourlyAggregationReturnsFormattedResults` — mock native SQL returning `[['hour_slot' => 9, 'duration' => 7200, 'count' => 5]]`, assert result is `[['hour' => 9, 'hours' => 2.0, 'count' => 5]]`
- `testGetHourlyAggregationReturnsEmptyForNoData` — mock empty results, assert empty array
- `testGetDayHourAggregationReturnsFormattedResults` — mock native SQL returning `[['dow' => 2, 'hour_slot' => 14, 'duration' => 3600, 'count' => 1]]`, with weekStart='monday', assert `[['day' => 0, 'hour' => 14, 'hours' => 1.0, 'count' => 1]]` (MySQL dow=2 is Monday, which is day=0 for monday-start)
- `testGetDayHourAggregationSundayStart` — same input but weekStart='sunday', assert `[['day' => 1, 'hour' => 14, 'hours' => 1.0, 'count' => 1]]` (Monday is day=1 for sunday-start)
- `testGetDailyAggregationWithActivityFilter` — verify activity filter is applied (use existing mock pattern, assert service call doesn't throw)
- `testGetDailyAggregationWithCustomerFilter` — verify customer filter with join is applied
- `testGetUserCustomersReturnsFormattedResults` — mock QB returning `[['customerId' => 1, 'name' => 'Acme']]`, assert `[['id' => 1, 'name' => 'Acme']]`
- `testGetUserActivitiesReturnsFormattedResults` — mock QB returning `[['activityId' => 5, 'name' => 'Dev']]`, assert `[['id' => 5, 'name' => 'Dev']]`
- `testGetUserActivitiesWithProjectScope` — same as above but with projectId param, assert no error
**Service implementation in HeatmapService.php:**
1. **Extend getDailyAggregation signature** — add `?int $customerId = null, ?int $activityId = null` after `$projectId`. Add filter logic:
```php
if ($activityId !== null) {
$qb->andWhere($qb->expr()->eq('t.activity', ':activity'))
->setParameter('activity', $activityId);
}
if ($customerId !== null) {
$qb->join('t.project', 'p')
->andWhere($qb->expr()->eq('p.customer', ':customer'))
->setParameter('customer', $customerId);
}
```
2. **Add getHourlyAggregation** — uses native SQL via DBAL Connection:
- Accepts `User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null`
- Compute timezone offset: `$tz = new \DateTimeZone($user->getTimezone()); $offset = $tz->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))); $offsetStr = sprintf('%+03d:%02d', intdiv($offset, 3600), abs(intdiv($offset % 3600, 60)));`
- SQL: `SELECT HOUR(CONVERT_TZ(t.start_time, '+00:00', :tz)) as hour_slot, COALESCE(SUM(t.duration), 0) as duration, COUNT(t.id) as count FROM kimai2_timesheet t WHERE t.user = :user AND t.date_tz BETWEEN :begin AND :end AND t.end_time IS NOT NULL`
- Append filter clauses: `AND t.project_id = :project` (if projectId), `INNER JOIN kimai2_projects p ON t.project_id = p.id AND p.customer_id = :customer` (if customerId), `AND t.activity_id = :activity` (if activityId)
- `GROUP BY hour_slot ORDER BY hour_slot ASC`
- Map results: `['hour' => (int)$row['hour_slot'], 'hours' => round((int)$row['duration'] / 3600, 2), 'count' => (int)$row['count']]`
3. **Add getDayHourAggregation** — uses native SQL:
- Same signature as getHourlyAggregation plus `string $weekStart = 'monday'`
- SQL: `SELECT DAYOFWEEK(t.date_tz) as dow, HOUR(CONVERT_TZ(t.start_time, '+00:00', :tz)) as hour_slot, COALESCE(SUM(t.duration), 0) as duration, COUNT(t.id) as count FROM kimai2_timesheet t WHERE ...`
- Same filter append logic as getHourlyAggregation
- `GROUP BY dow, hour_slot ORDER BY dow, hour_slot`
- Remap MySQL DAYOFWEEK to 0-6 relative to weekStart:
```php
$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2;
$dayIndex = ($mysqlDow - $weekStartOffset + 7) % 7;
```
- Map results: `['day' => $dayIndex, 'hour' => (int)$row['hour_slot'], 'hours' => round((int)$row['duration'] / 3600, 2), 'count' => (int)$row['count']]`
4. **Add getUserCustomers** — DQL via QueryBuilder:
```php
public function getUserCustomers(User $user): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('DISTINCT IDENTITY(p.customer) as customerId, c.name')
->join('t.project', 'p')
->join('p.customer', 'c')
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('user', $user)
->orderBy('c.name', 'ASC');
return array_map(fn(array $row) => [
'id' => (int) $row['customerId'],
'name' => $row['name'],
], $qb->getQuery()->getResult());
}
```
5. **Add getUserActivities** — DQL via QueryBuilder:
```php
public function getUserActivities(User $user, ?int $projectId = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('DISTINCT IDENTITY(t.activity) as activityId, a.name')
->join('t.activity', 'a')
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('user', $user)
->orderBy('a.name', 'ASC');
if ($projectId !== null) {
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
->setParameter('project', $projectId);
}
return array_map(fn(array $row) => [
'id' => (int) $row['activityId'],
'name' => $row['name'],
], $qb->getQuery()->getResult());
}
```
</action>
<verify>
<automated>php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter "testGetHourly|testGetDayHour|testGetDailyAggregationWith|testGetUserCustomers|testGetUserActivities"</automated>
</verify>
<acceptance_criteria>
- Service/HeatmapService.php contains `public function getHourlyAggregation(`
- Service/HeatmapService.php contains `public function getDayHourAggregation(`
- Service/HeatmapService.php contains `public function getUserCustomers(`
- Service/HeatmapService.php contains `public function getUserActivities(`
- Service/HeatmapService.php contains `CONVERT_TZ(t.start_time, '+00:00', :tz)`
- Service/HeatmapService.php contains `DAYOFWEEK(t.date_tz)`
- Service/HeatmapService.php contains `$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2`
- getDailyAggregation signature contains `?int $customerId = null, ?int $activityId = null`
- Service/HeatmapService.php contains `->join('t.project', 'p')` for customer filter
- Service/HeatmapService.php contains `t.activity_id = :activity` for activity filter in native SQL
- Service/HeatmapService.php contains `eq('t.activity', ':activity')` for activity filter in DQL
- Tests/Service/HeatmapServiceTest.php contains `testGetHourlyAggregationReturnsFormattedResults`
- Tests/Service/HeatmapServiceTest.php contains `testGetDayHourAggregationReturnsFormattedResults`
- Tests/Service/HeatmapServiceTest.php contains `testGetDayHourAggregationSundayStart`
- Tests/Service/HeatmapServiceTest.php contains `testGetUserCustomersReturnsFormattedResults`
- Tests/Service/HeatmapServiceTest.php contains `testGetUserActivitiesReturnsFormattedResults`
- Tests/Service/HeatmapServiceTest.php contains `createServiceWithNativeResults`
- PHPUnit exits 0 for all new tests
</acceptance_criteria>
<done>All 4 new service methods implemented with timezone-correct SQL, filter support on all aggregations, and all tests green</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP request -> HeatmapService | Query params (mode, project, customer, activity) are untrusted user input |
| HeatmapService -> Database | SQL queries must use parameterized binding, never string concatenation |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-01 | Tampering | Native SQL queries | mitigate | All values passed via DBAL named parameters (:tz, :user, :begin, :end, :project, :customer, :activity) — never concatenated into SQL string |
| T-08-02 | Information Disclosure | Aggregation queries | mitigate | All queries include `t.user = :user` constraint — user can only see own timesheet data |
| T-08-03 | Tampering | Filter ID params | mitigate | Integer type enforced via `getInt()` in controller (Plan 02); service accepts `?int` typed params |
</threat_model>
<verification>
- `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` exits 0
- HeatmapService has 6 public methods: getDailyAggregation, getHourlyAggregation, getDayHourAggregation, getUserProjects, getUserCustomers, getUserActivities
- All native SQL uses parameterized queries (no string interpolation in SQL)
</verification>
<success_criteria>
- All new service methods return correctly shaped data per D-06 and D-07 contracts
- Filter params (activity, customer) narrow results via parameterized WHERE clauses
- Timezone offset computed from user's timezone setting per D-08
- Day-of-week index remapped relative to weekStart per 08-RESEARCH.md Pattern 5
- All PHPUnit tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/08-backend-aggregation-filtering/08-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,360 @@
---
phase: 08-backend-aggregation-filtering
plan: 02
type: execute
wave: 2
depends_on: ["08-01"]
files_modified:
- Controller/HeatmapController.php
- Tests/Controller/HeatmapControllerTest.php
- assets/src/types.ts
autonomous: true
requirements: [API-01, API-02, FILT-02, FILT-03, TEST-03]
must_haves:
truths:
- "/heatmap/data?mode=hourly returns hourly aggregation JSON"
- "/heatmap/data?mode=dayhour returns day/hour matrix JSON"
- "/heatmap/data?activity=N narrows results to that activity"
- "/heatmap/data?customer=N narrows results to that customer's projects"
- "/heatmap/customers returns user's customer list"
- "/heatmap/projects returns user's project list (optionally filtered by customer)"
- "/heatmap/activities returns user's activity list (optionally filtered by project)"
- "TypeScript types exist for hourly and day/hour response shapes"
artifacts:
- path: "Controller/HeatmapController.php"
provides: "Mode dispatch, filter params, cascade endpoints"
exports: ["data", "customers", "projects", "activities"]
- path: "Tests/Controller/HeatmapControllerTest.php"
provides: "Unit tests for mode dispatch and cascade endpoints"
- path: "assets/src/types.ts"
provides: "HourEntry, DayHourEntry, HourlyData, DayHourData types"
key_links:
- from: "Controller/HeatmapController.php"
to: "Service/HeatmapService.php"
via: "Symfony DI injection"
pattern: "service->getHourlyAggregation|service->getDayHourAggregation|service->getUserCustomers|service->getUserActivities"
---
<objective>
Extend HeatmapController with mode dispatch, filter parameters, and cascade entity endpoints. Add TypeScript types for new response shapes.
Purpose: Wire the service methods from Plan 01 to HTTP endpoints, making aggregation data and entity lists available to the frontend. Cascade endpoints serve Phase 10 pickers.
Output: Extended controller with 4 actions (data with mode/filters, customers, projects, activities), tests, and TS types.
</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/08-backend-aggregation-filtering/08-CONTEXT.md
@.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
@.planning/phases/08-backend-aggregation-filtering/08-UI-SPEC.md
@.planning/phases/08-backend-aggregation-filtering/08-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 — new service methods available -->
From Service/HeatmapService.php (after Plan 01):
```php
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array;
public function getHourlyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array;
public function getDayHourAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null, string $weekStart = 'monday'): array;
public function getUserProjects(User $user): array;
public function getUserCustomers(User $user): array;
public function getUserActivities(User $user, ?int $projectId = null): array;
```
From Controller/HeatmapController.php (current):
```php
#[Route(path: '/heatmap')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
class HeatmapController extends AbstractController
{
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function data(Request $request, HeatmapService $service): JsonResponse
}
```
From assets/src/types.ts (current):
```typescript
export interface DayEntry { date: string; hours: number; count: number; }
export interface HeatmapData { days: DayEntry[]; range: { begin: string; end: string; }; }
export interface ProjectOption { id: number; name: string; }
export type DisplayMetric = 'hours' | 'count';
export type HeatmapMode = 'year' | 'week' | 'day' | 'combined';
export interface FilterState { projectId: number | null; customerId: number | null; activityId: number | null; }
```
From Tests/Controller/HeatmapControllerTest.php (current mock pattern):
```php
// Mock user via container/tokenStorage pattern
$container->method('has')->willReturn(true);
$container->method('get')->willReturn($tokenStorage);
$controller->setContainer($container);
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend HeatmapController with mode dispatch and cascade endpoints</name>
<files>Controller/HeatmapController.php, Tests/Controller/HeatmapControllerTest.php</files>
<read_first>
- Controller/HeatmapController.php (current data action)
- Tests/Controller/HeatmapControllerTest.php (existing mock pattern)
- Service/HeatmapService.php (Plan 01 output — verify new method signatures)
- .planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md (Pattern 1: Mode Dispatch, Pattern 4: Cascade)
- .planning/phases/08-backend-aggregation-filtering/08-01-SUMMARY.md (confirm service method signatures)
</read_first>
<behavior>
- Test: data() with mode=hourly calls getHourlyAggregation and returns JSON with 'hours' key
- Test: data() with mode=dayhour calls getDayHourAggregation and returns JSON with 'matrix' key
- Test: data() with no mode (default) calls getDailyAggregation and returns JSON with 'days' key
- Test: data() passes activity and customer params to service method
- Test: customers() returns JSON array from getUserCustomers
- Test: projects() returns JSON array from getUserProjects (existing method)
- Test: projects() with customer param passes it to the service (filtered query would be in service)
- Test: activities() returns JSON array from getUserActivities
- Test: activities() with project param passes it to getUserActivities
</behavior>
<action>
**Write tests first (RED), then implement (GREEN).**
**Test additions to HeatmapControllerTest.php:**
Add a helper method to create controller with mocked user (extract from existing test):
```php
private function createControllerWithUser(): array
{
$user = $this->createMock(User::class);
$user->method('getTimezone')->willReturn('Europe/Berlin');
// Kimai's User has getFirstDayOfWeek() returning 'monday' or 'sunday'
$user->method('getFirstDayOfWeek')->willReturn('monday');
$controller = new HeatmapController();
$container = $this->createMock(\Symfony\Component\DependencyInjection\ContainerInterface::class);
$tokenStorage = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface::class);
$token = $this->createMock(\Symfony\Component\Security\Core\Authentication\Token\TokenInterface::class);
$token->method('getUser')->willReturn($user);
$tokenStorage->method('getToken')->willReturn($token);
$container->method('has')->willReturn(true);
$container->method('get')->willReturn($tokenStorage);
$controller->setContainer($container);
return [$controller, $user];
}
```
Add test methods:
- `testDataReturnsHourlyMode` — create service mock where `getHourlyAggregation` returns `[['hour' => 9, 'hours' => 2.0, 'count' => 5]]`, request with `?mode=hourly`, assert response JSON has `hours` key with that array and `range` key
- `testDataReturnsDayHourMode` — service mock where `getDayHourAggregation` returns `[['day' => 0, 'hour' => 9, 'hours' => 1.0, 'count' => 1]]`, request with `?mode=dayhour`, assert response JSON has `matrix` key
- `testDataDefaultsToDailyMode` — service mock `getDailyAggregation` returns mock days, request with no mode param, assert `days` key present
- `testDataPassesFilterParams` — request with `?activity=5&customer=3`, verify service method receives these values (use `$service->expects($this->once())->method('getDailyAggregation')->with(...)`)
- `testCustomersEndpoint` — service mock `getUserCustomers` returns `[['id' => 1, 'name' => 'Acme']]`, call customers(), assert JSON response matches
- `testProjectsEndpoint` — service mock `getUserProjects` returns `[['id' => 1, 'name' => 'Web']]`, call projects(), assert JSON matches
- `testActivitiesEndpoint` — service mock `getUserActivities` returns `[['id' => 1, 'name' => 'Dev']]`, call activities(), assert JSON matches
- `testActivitiesWithProjectParam` — request with `?project=3`, verify `getUserActivities` called with projectId=3
**Controller implementation in HeatmapController.php:**
1. **Rewrite the `data()` action** with mode dispatch and filter params per D-02, D-03:
```php
#[Route(path: '/data', name: 'heatmap_data', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function data(Request $request, HeatmapService $service): JsonResponse
{
$user = $this->getUser();
$end = new \DateTimeImmutable('today');
$begin = $end->modify('-1 year');
$mode = $request->query->getString('mode', 'daily');
$projectId = $request->query->getInt('project') ?: null;
$customerId = $request->query->getInt('customer') ?: null;
$activityId = $request->query->getInt('activity') ?: null;
$range = ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')];
return match ($mode) {
'hourly' => new JsonResponse([
'hours' => $service->getHourlyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
'range' => $range,
]),
'dayhour' => new JsonResponse([
'matrix' => $service->getDayHourAggregation($user, $begin, $end, $projectId, $customerId, $activityId, $user->getFirstDayOfWeek()),
'range' => $range,
]),
default => new JsonResponse([
'days' => $service->getDailyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
'range' => $range,
]),
};
}
```
2. **Add customers() action** per D-04:
```php
#[Route(path: '/customers', name: 'heatmap_customers', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function customers(HeatmapService $service): JsonResponse
{
return new JsonResponse($service->getUserCustomers($this->getUser()));
}
```
3. **Add projects() action** — extend existing getUserProjects with optional customer filter. NOTE: the existing `getUserProjects()` method takes only User. For customer-scoped project filtering, add a `?int $customerId = null` param to the service method signature if not already done, OR query projects from timesheets filtered by customer. Since D-04 says `/heatmap/projects?customer={id}`, add customer filtering:
```php
#[Route(path: '/projects', name: 'heatmap_projects', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function projects(Request $request, HeatmapService $service): JsonResponse
{
$customerId = $request->query->getInt('customer') ?: null;
return new JsonResponse($service->getUserProjects($this->getUser(), $customerId));
}
```
IMPORTANT: This requires extending `getUserProjects(User $user, ?int $customerId = null)` in the service. Add the optional customer filter to getUserProjects:
```php
if ($customerId !== null) {
$qb->join('p.customer', 'c')
->andWhere($qb->expr()->eq('c.id', ':customer'))
->setParameter('customer', $customerId);
}
```
4. **Add activities() action** per D-04:
```php
#[Route(path: '/activities', name: 'heatmap_activities', methods: ['GET'])]
#[IsGranted('view_own_timesheet')]
public function activities(Request $request, HeatmapService $service): JsonResponse
{
$projectId = $request->query->getInt('project') ?: null;
return new JsonResponse($service->getUserActivities($this->getUser(), $projectId));
}
```
</action>
<verify>
<automated>php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter "testDataReturnsHourly|testDataReturnsDayHour|testDataDefaultsToDaily|testDataPassesFilter|testCustomersEndpoint|testProjectsEndpoint|testActivitiesEndpoint|testActivitiesWithProject"</automated>
</verify>
<acceptance_criteria>
- Controller/HeatmapController.php contains `$mode = $request->query->getString('mode', 'daily')`
- Controller/HeatmapController.php contains `return match ($mode)`
- Controller/HeatmapController.php contains `'hourly' => new JsonResponse`
- Controller/HeatmapController.php contains `'dayhour' => new JsonResponse`
- Controller/HeatmapController.php contains `$request->query->getInt('customer')`
- Controller/HeatmapController.php contains `$request->query->getInt('activity')`
- Controller/HeatmapController.php contains `public function customers(`
- Controller/HeatmapController.php contains `public function projects(`
- Controller/HeatmapController.php contains `public function activities(`
- Controller/HeatmapController.php contains `#[Route(path: '/customers'`
- Controller/HeatmapController.php contains `#[Route(path: '/projects'`
- Controller/HeatmapController.php contains `#[Route(path: '/activities'`
- All 4 actions have `#[IsGranted('view_own_timesheet')]`
- Tests/Controller/HeatmapControllerTest.php contains `testDataReturnsHourlyMode`
- Tests/Controller/HeatmapControllerTest.php contains `testDataReturnsDayHourMode`
- Tests/Controller/HeatmapControllerTest.php contains `testCustomersEndpoint`
- Tests/Controller/HeatmapControllerTest.php contains `testActivitiesEndpoint`
- PHPUnit exits 0 for all controller tests
</acceptance_criteria>
<done>Controller dispatches by mode, passes filters, serves cascade endpoints, all tests green</done>
</task>
<task type="auto">
<name>Task 2: Add TypeScript types for new API response shapes</name>
<files>assets/src/types.ts</files>
<read_first>
- assets/src/types.ts (current types)
- .planning/phases/08-backend-aggregation-filtering/08-UI-SPEC.md (TypeScript Type Extensions section)
</read_first>
<action>
Add the following interfaces to `assets/src/types.ts` after the existing `HeatmapData` interface, per the UI-SPEC contract:
```typescript
export interface HourEntry {
hour: number; // 0-23
hours: number;
count: number;
}
export interface DayHourEntry {
day: number; // 0-6, relative to weekStart
hour: number; // 0-23
hours: number;
count: number;
}
export interface HourlyData {
hours: HourEntry[];
range: { begin: string; end: string };
}
export interface DayHourData {
matrix: DayHourEntry[];
range: { begin: string; end: string };
}
```
These types are consumed by Phase 9 renderers. They match the JSON response contracts from the controller (mode=hourly returns HourlyData, mode=dayhour returns DayHourData).
</action>
<verify>
<automated>cd /home/toph/code/toph/kimai-heatmap && npx tsc --noEmit --project assets/tsconfig.json 2>&1 || echo "TS check done"</automated>
</verify>
<acceptance_criteria>
- assets/src/types.ts contains `export interface HourEntry`
- assets/src/types.ts contains `export interface DayHourEntry`
- assets/src/types.ts contains `export interface HourlyData`
- assets/src/types.ts contains `export interface DayHourData`
- assets/src/types.ts contains `hours: HourEntry[]`
- assets/src/types.ts contains `matrix: DayHourEntry[]`
- TypeScript compilation passes without errors (or no tsconfig exists, which is fine)
</acceptance_criteria>
<done>TypeScript types for hourly and day/hour API responses exist in types.ts</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| HTTP query params -> Controller | mode, project, customer, activity params are untrusted |
| Controller -> Service | Integer IDs type-cast via getInt(), mode validated via match() default |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-04 | Tampering | mode query param | mitigate | `match($mode)` with `default` case falls through to daily — invalid modes cannot reach aggregation queries |
| T-08-05 | Tampering | filter ID params | mitigate | `$request->query->getInt()` returns 0 for non-integer input, converted to null via `?: null` — no string injection possible |
| T-08-06 | Elevation of Privilege | cascade endpoints | mitigate | All endpoints have `#[IsGranted('view_own_timesheet')]` + queries scoped to `t.user = :user` |
| T-08-07 | Information Disclosure | cascade entity lists | mitigate | getUserCustomers/Activities/Projects all filter by user's own timesheets — cannot enumerate other users' entities |
</threat_model>
<verification>
- `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` exits 0 (full suite)
- Controller has 4 public action methods: data, customers, projects, activities
- All routes use `#[IsGranted('view_own_timesheet')]`
- All query params use type-safe `getInt()` or `getString()`
- TypeScript types compile without errors
</verification>
<success_criteria>
- mode=hourly returns `{hours: [...], range: {...}}` per D-02, D-06
- mode=dayhour returns `{matrix: [...], range: {...}}` per D-02, D-07
- Default mode returns existing daily format (backward compatible)
- activity and customer params passed through to service per D-03
- Cascade endpoints return `[{id, name}]` per D-04, D-05
- TypeScript types match API response contracts per D-09
- All PHPUnit tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/08-backend-aggregation-filtering/08-02-SUMMARY.md`
</output>