feat(08-01): add aggregation methods and filter support to HeatmapService

- getHourlyAggregation with timezone-correct CONVERT_TZ
- getDayHourAggregation with weekStart-relative day index
- getUserCustomers and getUserActivities cascade queries
- activity/customer filter params on getDailyAggregation
- Inject EntityManagerInterface for testable DBAL access
This commit is contained in:
Christopher Mühl 2026-04-09 21:23:25 +02:00
parent 8a0e5de4a8
commit c28220c83f
No known key found for this signature in database
GPG key ID: 925AC7D69955293F
3 changed files with 194 additions and 11 deletions

View file

@ -4,17 +4,20 @@ namespace KimaiPlugin\KimaiHeatmapBundle\Service;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use Doctrine\ORM\EntityManagerInterface;
class HeatmapService
{
public function __construct(private readonly TimesheetRepository $repository)
{
public function __construct(
private readonly TimesheetRepository $repository,
private readonly EntityManagerInterface $entityManager,
) {
}
/**
* @return array<int, array{date: string, hours: float, count: int}>
*/
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null): array
public function getDailyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
{
$qb = $this->repository->createQueryBuilder('t');
@ -37,6 +40,17 @@ class HeatmapService
->setParameter('project', $projectId);
}
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);
}
$results = $qb->getQuery()->getResult();
return array_map(function (array $row) {
@ -48,6 +62,108 @@ class HeatmapService
}, $results);
}
/**
* @return array<int, array{hour: int, hours: float, count: int}>
*/
public function getHourlyAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null): array
{
$conn = $this->entityManager->getConnection();
$offsetStr = $this->computeTimezoneOffset($user);
$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'),
];
if ($projectId !== null) {
$sql .= ' AND t.project_id = :project';
$params['project'] = $projectId;
}
if ($activityId !== null) {
$sql .= ' AND t.activity_id = :activity';
$params['activity'] = $activityId;
}
if ($customerId !== null) {
$sql .= ' AND t.project_id IN (SELECT p.id FROM kimai2_projects p WHERE p.customer_id = :customer)';
$params['customer'] = $customerId;
}
$sql .= ' GROUP BY hour_slot ORDER BY hour_slot ASC';
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
return array_map(fn(array $row) => [
'hour' => (int) $row['hour_slot'],
'hours' => round((int) $row['duration'] / 3600, 2),
'count' => (int) $row['count'],
], $results);
}
/**
* @return array<int, array{day: int, hour: int, hours: float, count: int}>
*/
public function getDayHourAggregation(User $user, \DateTimeInterface $begin, \DateTimeInterface $end, ?int $projectId = null, ?int $customerId = null, ?int $activityId = null, string $weekStart = 'monday'): array
{
$conn = $this->entityManager->getConnection();
$offsetStr = $this->computeTimezoneOffset($user);
$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 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'),
];
if ($projectId !== null) {
$sql .= ' AND t.project_id = :project';
$params['project'] = $projectId;
}
if ($activityId !== null) {
$sql .= ' AND t.activity_id = :activity';
$params['activity'] = $activityId;
}
if ($customerId !== null) {
$sql .= ' AND t.project_id IN (SELECT p.id FROM kimai2_projects p WHERE p.customer_id = :customer)';
$params['customer'] = $customerId;
}
$sql .= ' GROUP BY dow, hour_slot ORDER BY dow, hour_slot';
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
$weekStartOffset = ($weekStart === 'sunday') ? 1 : 2;
return array_map(fn(array $row) => [
'day' => ((int) $row['dow'] - $weekStartOffset + 7) % 7,
'hour' => (int) $row['hour_slot'],
'hours' => round((int) $row['duration'] / 3600, 2),
'count' => (int) $row['count'],
], $results);
}
/**
* @return array<int, array{id: int, name: string}>
*/
@ -66,4 +182,58 @@ class HeatmapService
'name' => $row['name'],
], $qb->getQuery()->getResult());
}
/**
* @return array<int, array{id: int, name: string}>
*/
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());
}
/**
* @return array<int, array{id: int, name: string}>
*/
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());
}
private function computeTimezoneOffset(User $user): 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));
return sprintf('%+03d:%02d', $hours, $minutes);
}
}

View file

@ -248,7 +248,9 @@ class HeatmapServiceTest extends TestCase
$repo = $this->createMock(TimesheetRepository::class);
$repo->method('createQueryBuilder')->willReturn($qb);
return new HeatmapService($repo);
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
return new HeatmapService($repo, $em);
}
private function createServiceWithNativeResults(array $results): HeatmapService
@ -262,12 +264,8 @@ class HeatmapServiceTest extends TestCase
$em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
$em->method('getConnection')->willReturn($connection);
$repo = $this->getMockBuilder(TimesheetRepository::class)
->disableOriginalConstructor()
->onlyMethods(['getEntityManager'])
->getMock();
$repo->method('getEntityManager')->willReturn($em);
$repo = $this->createMock(TimesheetRepository::class);
return new HeatmapService($repo);
return new HeatmapService($repo, $em);
}
}

View file

@ -7,7 +7,22 @@ $autoloadPaths = [
foreach ($autoloadPaths as $path) {
if (file_exists($path)) {
require_once $path;
$loader = require_once $path;
// In worktree contexts, register a prepended autoloader so worktree classes
// take priority over the classmap entries pointing to the main repo symlink.
$pluginRoot = dirname(__DIR__) . '/';
spl_autoload_register(function (string $class) use ($pluginRoot) {
$prefix = 'KimaiPlugin\\KimaiHeatmapBundle\\';
if (str_starts_with($class, $prefix)) {
$relative = str_replace('\\', '/', substr($class, strlen($prefix)));
$file = $pluginRoot . $relative . '.php';
if (file_exists($file)) {
require $file;
return true;
}
}
return false;
}, true, true); // prepend=true
return;
}
}