docs(08): create phase plan
This commit is contained in:
parent
449366eb51
commit
09112efd7e
3 changed files with 649 additions and 2 deletions
|
|
@ -68,7 +68,10 @@ Plans:
|
||||||
3. Customer filter param narrows heatmap data to all projects under a selected customer
|
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)
|
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
|
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
|
### Phase 9: Day + Combined Modes
|
||||||
**Goal**: Users can view time-of-day and day/hour punchcard visualizations with a color scale legend
|
**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 |
|
| 5. Polish | v1.0 | 2/2 | Complete | 2026-04-08 |
|
||||||
| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - |
|
| 6. Renderer Architecture | v1.1 | 0/2 | Planned | - |
|
||||||
| 7. Mode Switcher + Week Mode | 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 | - |
|
| 9. Day + Combined Modes | v1.1 | 0/? | Not started | - |
|
||||||
| 10. Entity Pickers | v1.1 | 0/? | Not started | - |
|
| 10. Entity Pickers | v1.1 | 0/? | Not started | - |
|
||||||
|
|
|
||||||
284
.planning/phases/08-backend-aggregation-filtering/08-01-PLAN.md
Normal file
284
.planning/phases/08-backend-aggregation-filtering/08-01-PLAN.md
Normal 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>
|
||||||
360
.planning/phases/08-backend-aggregation-filtering/08-02-PLAN.md
Normal file
360
.planning/phases/08-backend-aggregation-filtering/08-02-PLAN.md
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue