diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 7757187..dd854da 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -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 | - |
diff --git a/.planning/phases/08-backend-aggregation-filtering/08-01-PLAN.md b/.planning/phases/08-backend-aggregation-filtering/08-01-PLAN.md
new file mode 100644
index 0000000..3e62fec
--- /dev/null
+++ b/.planning/phases/08-backend-aggregation-filtering/08-01-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/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
+
+
+
+@.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
+
+
+
+
+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();
+```
+
+
+
+
+
+
+ Task 1: Add aggregation methods and filter support to HeatmapService
+ Service/HeatmapService.php, Tests/Service/HeatmapServiceTest.php
+
+ - 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)
+
+
+ - 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
+
+
+**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());
+ }
+ ```
+
+
+ php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter "testGetHourly|testGetDayHour|testGetDailyAggregationWith|testGetUserCustomers|testGetUserActivities"
+
+
+ - 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
+
+ All 4 new service methods implemented with timezone-correct SQL, filter support on all aggregations, and all tests green
+
+
+
+
+
+## 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 |
+
+
+
+- `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)
+
+
+
+- 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
+
+
+
diff --git a/.planning/phases/08-backend-aggregation-filtering/08-02-PLAN.md b/.planning/phases/08-backend-aggregation-filtering/08-02-PLAN.md
new file mode 100644
index 0000000..6b27f09
--- /dev/null
+++ b/.planning/phases/08-backend-aggregation-filtering/08-02-PLAN.md
@@ -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"
+---
+
+
+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.
+
+
+
+@/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
+
+
+
+@.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
+
+
+
+
+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);
+```
+
+
+
+
+
+
+ Task 1: Extend HeatmapController with mode dispatch and cascade endpoints
+ Controller/HeatmapController.php, Tests/Controller/HeatmapControllerTest.php
+
+ - 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)
+
+
+ - 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
+
+
+**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));
+ }
+ ```
+
+
+ php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter "testDataReturnsHourly|testDataReturnsDayHour|testDataDefaultsToDaily|testDataPassesFilter|testCustomersEndpoint|testProjectsEndpoint|testActivitiesEndpoint|testActivitiesWithProject"
+
+
+ - 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
+
+ Controller dispatches by mode, passes filters, serves cascade endpoints, all tests green
+
+
+
+ Task 2: Add TypeScript types for new API response shapes
+ assets/src/types.ts
+
+ - assets/src/types.ts (current types)
+ - .planning/phases/08-backend-aggregation-filtering/08-UI-SPEC.md (TypeScript Type Extensions section)
+
+
+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).
+
+
+ cd /home/toph/code/toph/kimai-heatmap && npx tsc --noEmit --project assets/tsconfig.json 2>&1 || echo "TS check done"
+
+
+ - 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)
+
+ TypeScript types for hourly and day/hour API responses exist in types.ts
+
+
+
+
+
+## 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 |
+
+
+
+- `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
+
+
+
+- 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
+
+
+