docs(08): research phase domain

This commit is contained in:
Christopher Mühl 2026-04-09 21:06:35 +02:00
parent 96d70dd160
commit 9cb09ec839
No known key found for this signature in database
GPG key ID: 925AC7D69955293F

View file

@ -0,0 +1,443 @@
# Phase 8: Backend Aggregation + Filtering - Research
**Researched:** 2026-04-09
**Domain:** Symfony/Doctrine PHP backend, SQL aggregation, timezone handling
**Confidence:** HIGH
## Summary
Phase 8 extends the existing `HeatmapService` and `HeatmapController` with two new aggregation modes (hourly, day/hour) and three filter parameters (activity, customer, plus existing project). It also adds three cascade entity endpoints for the Phase 10 TomSelect pickers. All changes are backend PHP + PHPUnit tests with minimal frontend touch (URL parameter construction only).
The critical technical challenge is **timezone-correct hour extraction**. Kimai stores `start_time` in UTC but users operate in local timezones. MariaDB's `CONVERT_TZ` with numeric offsets (e.g., `'+02:00'`) is the most reliable approach -- it does not depend on MySQL timezone tables being loaded. The `date_tz` column (already in user timezone) can be used for day-of-week grouping.
**Primary recommendation:** Use native SQL queries (via DBAL Connection) for the hourly and day/hour aggregations since Kimai only registers DATE/DAY/MONTH/YEAR as custom DQL functions -- there is no HOUR function. Keep the existing DQL QueryBuilder pattern for the daily aggregation and cascade endpoints.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
No locked decisions -- user deferred all to Claude's discretion.
### Claude's Discretion
- D-01: Extend HeatmapService with separate getHourlyAggregation() and getDayHourAggregation() methods
- D-02: Extend existing /heatmap/data endpoint with optional mode query param (daily/hourly/dayhour)
- D-03: Add activity and customer query params to /heatmap/data endpoint with AND logic
- D-04: Cascade endpoints: /heatmap/customers, /heatmap/projects?customer={id}, /heatmap/activities?project={id}
- D-05: Cascade response format: [{id: number, name: string}]
- D-06: Hourly aggregation returns {hour: 0-23, hours: float, count: int}
- D-07: Day/hour aggregation returns {day: 0-6, hour: 0-23, hours: float, count: int}
- D-08: Use Kimai user timezone for hour grouping
- D-09: Frontend extends existing fetch with mode/filter params
### Deferred Ideas (OUT OF SCOPE)
None.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| API-01 | API endpoint accepts mode param returning hour-level and day/hour aggregation data | New service methods + mode param dispatch in controller (see Architecture Patterns) |
| API-02 | API endpoint accepts activity and customer filter params with own controller endpoints | Filter params on /heatmap/data + 3 cascade endpoints (see Cascade Endpoints pattern) |
| FILT-02 | Activity filtering narrows heatmap data to a specific activity | QueryBuilder WHERE on t.activity (see Filter Implementation pattern) |
| FILT-03 | Customer filtering narrows heatmap data to all projects under a customer | JOIN through t.project -> p.customer (see Filter Implementation pattern) |
| TEST-03 | PHPUnit tests for hour-level and day/hour aggregation queries | Extend existing mock pattern from HeatmapServiceTest (see Validation Architecture) |
</phase_requirements>
## Architecture Patterns
### Recommended Changes Structure
```
Controller/HeatmapController.php # extend with mode dispatch + cascade actions
Service/HeatmapService.php # add 3 new methods + extend existing with filters
Tests/Controller/HeatmapControllerTest.php # extend with new action tests
Tests/Service/HeatmapServiceTest.php # extend with new method tests
assets/src/types.ts # add HourEntry, DayHourEntry, HourlyData, DayHourData types
```
### Pattern 1: Mode Dispatch in Controller
**What:** The existing `data()` action reads a `mode` query param and dispatches to the appropriate service method. [VERIFIED: existing controller code]
**Example:**
```php
// Source: extending existing HeatmapController.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;
return match ($mode) {
'hourly' => new JsonResponse([
'hours' => $service->getHourlyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
]),
'dayhour' => new JsonResponse([
'matrix' => $service->getDayHourAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
]),
default => new JsonResponse([
'days' => $service->getDailyAggregation($user, $begin, $end, $projectId, $customerId, $activityId),
'range' => ['begin' => $begin->format('Y-m-d'), 'end' => $end->format('Y-m-d')],
]),
};
}
```
### Pattern 2: Timezone-Correct Hour Extraction via Native SQL
**What:** Use DBAL native SQL with `CONVERT_TZ` for hour grouping since Kimai has no HOUR DQL function registered. [VERIFIED: doctrine.yaml only registers DATE/DAY/MONTH/YEAR]
**Why native SQL:** Kimai's custom DQL functions (in `App\Doctrine\Extensions\`) only cover DATE, DAY, MONTH, YEAR. Adding a plugin-level DQL function would require modifying Kimai's Doctrine config, which plugins should not do. Native SQL via DBAL Connection is clean and explicit.
**Critical timezone detail:** `start_time` is stored in UTC. Each timesheet has a `timezone` column. The user's current timezone is available via `$user->getTimezone()`. We compute a numeric offset string (e.g., `'+02:00'`) from the user timezone and use `CONVERT_TZ(t.start_time, '+00:00', :tz_offset)`. This avoids requiring MySQL timezone tables. [VERIFIED: UTCDateTimeType.php confirms UTC storage; Timesheet entity confirms timezone column]
**Example:**
```php
// Source: pattern derived from existing codebase analysis
public function getHourlyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
{
$tz = new \DateTimeZone($user->getTimezone());
$offset = $tz->getOffset(new \DateTime('now', $tz));
$offsetStr = sprintf('%+03d:%02d', intdiv($offset, 3600), abs($offset % 3600) / 60);
$conn = $this->repository->getEntityManager()->getConnection();
$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';
$params = [
'tz' => $offsetStr,
'user' => $user->getId(),
'begin' => $begin->format('Y-m-d'),
'end' => $end->format('Y-m-d'),
];
// append optional filters...
// $sql .= ' GROUP BY hour_slot ORDER BY hour_slot ASC';
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
// map to response format
}
```
**Offset caveat:** A single offset computed at request time is correct for users in fixed-offset timezones. For DST timezones, entries created during summer will be off by 1 hour when viewed in winter (and vice versa). This is an acceptable tradeoff for an aggregation heatmap -- Kimai's own `date_tz` column has the same limitation (stores the date at creation time, not retroactively adjusted). [ASSUMED]
### Pattern 3: Filter Implementation
**What:** Activity and customer filters are additive WHERE clauses on all aggregation queries. [VERIFIED: existing project filter pattern in HeatmapService]
**Customer filter requires a JOIN** since timesheets reference projects, and projects reference customers:
```sql
-- Activity filter (direct relation)
AND t.activity_id = :activity
-- Customer filter (through project)
INNER JOIN kimai2_projects p ON t.project_id = p.id
AND p.customer_id = :customer
```
For DQL (daily aggregation extending existing QueryBuilder):
```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);
}
```
### Pattern 4: Cascade Entity Endpoints
**What:** Three new controller actions returning entity lists scoped to user's own timesheets. [VERIFIED: getUserProjects() pattern in HeatmapService]
```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()));
}
```
Service methods follow the existing `getUserProjects()` pattern -- query timesheets, join to related entity, select distinct, return `[{id, name}]`.
### Pattern 5: Day/Hour Aggregation with weekStart
**What:** The day/hour (punchcard) mode groups by day-of-week AND hour-of-day. Day index is relative to user's `weekStart` preference.
**Day-of-week from `date_tz`:** Since `date_tz` already stores the date in user timezone, `DAYOFWEEK(date_tz)` gives the correct day. MySQL's DAYOFWEEK returns 1=Sunday through 7=Saturday. We need to remap to 0-6 relative to weekStart in PHP. [VERIFIED: Timesheet entity doc comment confirms date_tz is in user timezone]
```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 ...
GROUP BY dow, hour_slot
ORDER BY dow, hour_slot
```
Then in PHP, remap MySQL's DAYOFWEEK (1=Sun,2=Mon,...,7=Sat) to 0-6 relative to weekStart:
```php
// MySQL DAYOFWEEK: 1=Sun, 2=Mon, ... 7=Sat
// If weekStart=monday: Mon=0, Tue=1, ..., Sun=6
// If weekStart=sunday: Sun=0, Mon=1, ..., Sat=6
$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2;
$dayIndex = ($mysqlDow - $weekStartOffset + 7) % 7;
```
### Anti-Patterns to Avoid
- **Registering custom DQL functions from a plugin:** Plugins should not modify Kimai's Doctrine configuration. Use native SQL instead. [VERIFIED: plugin architecture does not support custom DQL registration]
- **Using `CONVERT_TZ` with named timezones:** Requires MySQL `mysql.time_zone_name` table to be populated (often empty in Docker/dev setups). Use numeric offsets only. [ASSUMED]
- **Ignoring the `end IS NOT NULL` filter:** Running timesheets have no duration. Existing code correctly filters these. New queries must too. [VERIFIED: existing service code]
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Timezone offset calculation | Manual hour math | PHP DateTimeZone::getOffset() | Handles DST transitions correctly |
| Day-of-week calculation | PHP date() on each row | MySQL DAYOFWEEK() on date_tz | Database-side grouping is faster |
| SQL injection protection | String concatenation | Parameterized queries (DBAL params) | Standard security practice |
| JSON serialization | Manual json_encode | Symfony JsonResponse | Handles encoding, content-type headers |
## Common Pitfalls
### Pitfall 1: CONVERT_TZ Returns NULL
**What goes wrong:** `CONVERT_TZ` returns NULL when using named timezones (e.g., 'Europe/Berlin') and MySQL timezone tables are not loaded.
**Why it happens:** MariaDB/MySQL ship with empty timezone tables by default. Named timezone lookups fail silently.
**How to avoid:** Always use numeric offset format (`'+02:00'`), never named timezones.
**Warning signs:** Aggregation returns zero/empty results when data exists. [ASSUMED]
### Pitfall 2: DST Offset Mismatch
**What goes wrong:** A single offset computed at request time doesn't match offsets at the time entries were created. Summer entries shift by 1 hour when viewed in winter.
**Why it happens:** Timezones with DST have different UTC offsets at different times of year.
**How to avoid:** For a heatmap aggregation, this 1-hour shift is acceptable. If precision is needed later, each timesheet's own `timezone` column could be used per-row (expensive).
**Warning signs:** Hour bins near DST transitions show unexpected clustering. [ASSUMED]
### Pitfall 3: Table Name Mismatch
**What goes wrong:** Native SQL uses actual database table names, not Doctrine entity names.
**Why it happens:** DQL uses entity names (`Timesheet`, `Project`), but raw SQL needs table names (`kimai2_timesheet`, `kimai2_projects`).
**How to avoid:** Check the `@ORM\Table` annotation or use `$em->getClassMetadata(Timesheet::class)->getTableName()` to resolve dynamically.
**Warning signs:** "Table doesn't exist" SQL errors. [VERIFIED: Timesheet entity uses kimai2_timesheet table]
### Pitfall 4: Customer Filter Without Project Join
**What goes wrong:** Trying to filter by customer directly on the timesheet table fails -- there is no customer_id column on kimai2_timesheet.
**Why it happens:** Kimai's entity model is Timesheet -> Project -> Customer (no direct Timesheet -> Customer relation).
**How to avoid:** Always JOIN kimai2_projects when filtering by customer.
**Warning signs:** SQL column not found error. [VERIFIED: Timesheet entity has project and activity relations only]
### Pitfall 5: Missing Query Builder Methods in Mock
**What goes wrong:** PHPUnit tests fail because the mock QueryBuilder doesn't stub new chained methods (e.g., `join()`).
**Why it happens:** Existing test helper `createServiceWithResults()` stubs a fixed set of QB methods. New filter logic adds `join()`.
**How to avoid:** Add `$qb->method('join')->willReturnSelf()` to the test helper. For native SQL tests, mock the DBAL Connection instead.
**Warning signs:** "Call to undefined method" in test output. [VERIFIED: existing test mock pattern]
## Code Examples
### Native SQL Query with DBAL Connection
```php
// Source: Doctrine DBAL pattern [VERIFIED: TimesheetRepository uses getEntityManager()]
$conn = $this->repository->getEntityManager()->getConnection();
$result = $conn->executeQuery($sql, $params);
$rows = $result->fetchAllAssociative();
```
### Timezone Offset from User
```php
// Source: PHP DateTimeZone API [VERIFIED: User::getTimezone() returns timezone string]
$tz = new \DateTimeZone($user->getTimezone());
$offset = $tz->getOffset(new \DateTime('now', new \DateTimeZone('UTC')));
$hours = intdiv($offset, 3600);
$minutes = abs(intdiv($offset % 3600, 60));
$offsetStr = sprintf('%+03d:%02d', $hours, $minutes);
// e.g., "Europe/Berlin" in summer -> "+02:00"
```
### Cascade Query (getUserCustomers example)
```php
// Source: extending existing getUserProjects() pattern [VERIFIED: HeatmapService.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());
}
```
### getUserActivities with Optional Project Scope
```php
// Source: extending getUserProjects() pattern [VERIFIED: HeatmapService.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());
}
```
### Testing Native SQL with Mocked Connection
```php
// Source: PHPUnit mock pattern for DBAL [ASSUMED]
private function createServiceWithNativeResults(array $results): HeatmapService
{
// For native SQL methods, mock the DBAL connection
$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);
// Also set up createQueryBuilder for DQL methods
// ...
return new HeatmapService($repo);
}
```
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | PHPUnit 10.5 |
| Config file | `Tests/phpunit.xml` |
| Quick run command | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` |
| Full suite command | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml` |
### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| API-01 | Mode param dispatches to correct service method | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testDataReturnsHourly` | Wave 0 |
| API-01 | Hourly aggregation groups by hour, returns correct shape | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testGetHourlyAggregation` | Wave 0 |
| API-01 | Day/hour aggregation groups by day+hour, returns correct shape | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testGetDayHourAggregation` | Wave 0 |
| API-02 | Activity filter param narrows results | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testActivityFilter` | Wave 0 |
| API-02 | Customer filter param narrows results via project join | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testCustomerFilter` | Wave 0 |
| API-02 | Cascade endpoints return entity lists | unit | `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml --filter testCustomersEndpoint` | Wave 0 |
| FILT-02 | Activity filter applied to all aggregation modes | unit | covered by API-02 activity filter tests | Wave 0 |
| FILT-03 | Customer filter applied to all aggregation modes | unit | covered by API-02 customer filter tests | Wave 0 |
| TEST-03 | PHPUnit tests for aggregation queries | unit | full suite command | Wave 0 |
### Sampling Rate
- **Per task commit:** `php dev/kimai/vendor/bin/phpunit --configuration Tests/phpunit.xml`
- **Per wave merge:** same (single test suite)
- **Phase gate:** Full suite green before `/gsd-verify-work`
### Wave 0 Gaps
None -- existing test infrastructure (`Tests/phpunit.xml`, `Tests/bootstrap.php`, mock helpers in `HeatmapServiceTest.php`) covers all needs. New test methods extend existing test classes.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | Handled by Kimai (session auth) |
| V3 Session Management | no | Handled by Kimai |
| V4 Access Control | yes | `#[IsGranted('view_own_timesheet')]` + user-scoped queries |
| V5 Input Validation | yes | Query param type casting (`getInt()`, `getString()`) + parameterized SQL |
| V6 Cryptography | no | No crypto in this phase |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection via filter params | Tampering | Parameterized queries (DBAL params for native SQL, QueryBuilder setParameter for DQL) |
| IDOR on customer/project/activity IDs | Information Disclosure | All queries scoped to `t.user = :user` -- user can only see their own entities |
| Mode param injection | Tampering | `match()` with `default` case prevents invalid modes from reaching queries |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | CONVERT_TZ with numeric offsets works without timezone tables loaded | Pitfall 1 | Hour grouping returns NULL; fallback would be PHP-side processing |
| A2 | Single offset for DST timezones is acceptable for heatmap aggregation | Pitfall 2 | 1-hour shift in some entries; low visual impact |
| A3 | DBAL Result::fetchAllAssociative() is mockable in PHPUnit | Code Examples | Tests need different mock approach if class is final |
## Open Questions
1. **Table name resolution in native SQL**
- What we know: Timesheet table is `kimai2_timesheet`, projects is `kimai2_projects`, customers is `kimai2_customers`, activities is `kimai2_activities`
- What's unclear: Whether these names are configurable via Doctrine prefix
- Recommendation: Use `$em->getClassMetadata(Timesheet::class)->getTableName()` for safety, or hardcode since Kimai's table names are stable [ASSUMED]
## Sources
### Primary (HIGH confidence)
- `Controller/HeatmapController.php` -- existing endpoint pattern, route attributes, auth checks
- `Service/HeatmapService.php` -- existing QueryBuilder pattern, getUserProjects() shape
- `Tests/Service/HeatmapServiceTest.php` -- existing mock pattern for service tests
- `dev/kimai/config/packages/doctrine.yaml` -- confirmed DQL functions limited to DATE/DAY/MONTH/YEAR
- `dev/kimai/src/Doctrine/UTCDateTimeType.php` -- confirmed UTC storage
- `dev/kimai/src/Entity/Timesheet.php` -- confirmed entity relations, date_tz column, timezone column
- `dev/kimai/src/Entity/User.php` -- confirmed getTimezone() and getFirstDayOfWeek() APIs
- `dev/kimai/src/Entity/Project.php` -- confirmed customer relation
### Secondary (MEDIUM confidence)
- UI-SPEC (`08-UI-SPEC.md`) -- API response contracts for downstream phases
### Tertiary (LOW confidence)
- MariaDB CONVERT_TZ behavior with numeric offsets (not verified against running instance)
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH -- extending existing PHP patterns, no new libraries
- Architecture: HIGH -- all patterns verified against existing codebase
- Pitfalls: MEDIUM -- timezone handling assumptions need runtime validation
**Research date:** 2026-04-09
**Valid until:** 2026-05-09 (stable -- Kimai internals unlikely to change)