Release 2.15 (#4749)

This commit is contained in:
Kevin Papst
2024-04-12 19:03:09 +02:00
committed by GitHub
parent b76a5d5c35
commit 7219b3f421
56 changed files with 441 additions and 575 deletions

View File

@@ -1,206 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\DataProvider;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use DateTime;
use DateTimeInterface;
/**
* This class should really be deleted and replaced by TimesheetStatisticService::getDailyStatistics()
* @deprecated since 2.0
* @codeCoverageIgnore
* @internal
* @final
*/
class DailyWorkingTimeChartProvider
{
public function __construct(private readonly TimesheetRepository $repository)
{
}
/**
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
*
* @return array<mixed>
*/
protected function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, ?User $user = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$or = $qb->expr()->orX();
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin)
->setParameter('end', $end)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
if (null !== $user) {
$qb
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('user', $user)
;
}
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
/** @var DateTime $endTmp */
$endTmp = $result->getEnd();
$dateKeyEnd = $endTmp->format('Ymd');
do {
$dateKey = $beginTmp->format('Ymd');
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new \DateInterval('P1D'));
// overlapping records should always start at midnight
$newDateBegin->setTime(0, 0, 0);
} else {
$newDateBegin = clone $endTmp;
}
// make sure to exclude entries that are outside the requested time-range:
// these entries can exist if you have long running entries that started before $begin
// for statistical reasons we have to include everything between $begin and $end while
// excluding everything that is outside of that range
// --------------------------------------------------------------------------------------
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
// before $begin (which happens thanks to the timezone conversion when querying the database)
if ($newDateBegin > $begin && $beginTmp < $end) {
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'rate' => 0,
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
$durationPercent = $duration / $result->getDuration();
}
$rate = $result->getRate() * $durationPercent;
$results[$dateKey]['rate'] += $rate;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . $result->getProject()->getId()
. '_' . $result->getActivity()->getId()
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'rate' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
$beginTmp = $newDateBegin;
// yes, we only want to compare the day, not the time
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
break;
}
} while ($dateKey !== $dateKeyEnd);
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($results[$key]['details']);
}
return array_values($results);
}
/**
* @deprecated since 2.0 - use TimesheetStatisticService::getDailyStatistics() instead
*
* @param User|null $user
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @return Day[]
* @throws \Exception
*/
public function getData(?User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
}

View File

@@ -11,17 +11,21 @@ namespace App\Widget\Type;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use App\Widget\DataProvider\DailyWorkingTimeChartProvider;
use App\Widget\WidgetInterface;
use DateTime;
use DateTimeInterface;
/**
* This is rendered inside the PaginatedWorkingTimeChart.
*/
final class DailyWorkingTimeChart extends AbstractWidget
{
public function __construct(private DailyWorkingTimeChartProvider $dailyWorkingTimeChartProvider)
public function __construct(private readonly TimesheetRepository $repository)
{
}
@@ -47,7 +51,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
/**
* @param array<string, string|bool|int|null|array<string, mixed>> $options
@return array<string, string|bool|int|null|array<string, mixed>>
* @return array<string, string|bool|int|null|array<string, mixed>>
*/
public function getOptions(array $options = []): array
{
@@ -71,7 +75,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
$begin = $options['begin'];
if (!($begin instanceof \DateTimeInterface)) {
if (\is_string($begin)) {
$begin = new DateTime($begin, new \DateTimeZone($user->getTimezone()));
$begin = new \DateTimeImmutable($begin, new \DateTimeZone($user->getTimezone()));
} else {
$begin = $dateTimeFactory->getStartOfWeek();
}
@@ -80,14 +84,14 @@ final class DailyWorkingTimeChart extends AbstractWidget
$end = $options['end'];
if (!($end instanceof \DateTimeInterface)) {
if (\is_string($end)) {
$end = new DateTime($end, new \DateTimeZone($user->getTimezone()));
$end = new \DateTimeImmutable($end, new \DateTimeZone($user->getTimezone()));
} else {
$end = $dateTimeFactory->getEndOfWeek($begin);
}
}
$activities = [];
$statistics = $this->dailyWorkingTimeChartProvider->getData($user, $begin, $end);
$statistics = $this->getPreparedData($user, $begin, $end);
foreach ($statistics as $day) {
foreach ($day->getDetails() as $entry) {
@@ -125,4 +129,120 @@ final class DailyWorkingTimeChart extends AbstractWidget
{
return 'widget/widget-dailyworkingtimechart.html.twig';
}
/**
* @return list<array{'duration': int, 'billable': int, 'month': numeric-string, 'year': numeric-string, 'day': numeric-string, 'details': array<int|string, array<mixed>>}>
*/
private function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('t, p, a, c')
->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)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
$dateKey = $beginTmp->format('Ymd');
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $result->getDuration() ?? 0;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . ($result->getProject()?->getId() ?? '')
. '_' . ($result->getActivity()?->getId() ?? '')
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($value['details']);
}
return array_values($results);
}
/**
* @return Day[]
* @throws \Exception
*/
private function getPreparedData(User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
// TODO replace with TimesheetStatisticService::getDailyStatistics()
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate((int) $statRow['year'], (int) $statRow['month'], (int) $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], 0.00); // rate is not used in frontend
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
}