diff --git a/Service/HeatmapService.php b/Service/HeatmapService.php index fc5ba48..f2d505f 100644 --- a/Service/HeatmapService.php +++ b/Service/HeatmapService.php @@ -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 */ - 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 + */ + 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 + */ + 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 */ @@ -66,4 +182,58 @@ class HeatmapService 'name' => $row['name'], ], $qb->getQuery()->getResult()); } + + /** + * @return array + */ + 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 + */ + 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); + } } diff --git a/Tests/Service/HeatmapServiceTest.php b/Tests/Service/HeatmapServiceTest.php index d325b88..7ff6e87 100644 --- a/Tests/Service/HeatmapServiceTest.php +++ b/Tests/Service/HeatmapServiceTest.php @@ -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); } } diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php index 3511f0c..0435eec 100644 --- a/Tests/bootstrap.php +++ b/Tests/bootstrap.php @@ -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; } }