kimai-plugin-heatmap/Service/HeatmapService.php
Christopher Mühl c28220c83f
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
2026-04-09 21:23:25 +02:00

239 lines
8.5 KiB
PHP

<?php
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,
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, ?int $customerId = null, ?int $activityId = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COUNT(t.id) as count')
->addSelect('DATE(t.date) as day')
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $user)
->groupBy('day')
->orderBy('day', 'ASC')
;
if ($projectId !== null) {
$qb->andWhere($qb->expr()->eq('t.project', ':project'))
->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) {
return [
'date' => $row['day'],
'hours' => round((int) $row['duration'] / 3600, 2),
'count' => (int) $row['count'],
];
}, $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}>
*/
public function getUserProjects(User $user): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('DISTINCT IDENTITY(t.project) as projectId, p.name')
->join('t.project', 'p')
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('user', $user)
->orderBy('p.name', 'ASC');
return array_map(fn(array $row) => [
'id' => (int) $row['projectId'],
'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);
}
}