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 + + + +After completion, create `.planning/phases/08-backend-aggregation-filtering/08-01-SUMMARY.md` + 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 + + + +After completion, create `.planning/phases/08-backend-aggregation-filtering/08-02-SUMMARY.md` +