docs(08): research phase domain
This commit is contained in:
parent
96d70dd160
commit
9cb09ec839
1 changed files with 443 additions and 0 deletions
443
.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
Normal file
443
.planning/phases/08-backend-aggregation-filtering/08-RESEARCH.md
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue