Add unit tests for advanced target duration parsing, goal range calculations, and Workfeed query normalization. Extend OpenAPI spec with updated schemas and examples.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace goals\classes;
|
||||
require_once WD . '/modules/goals/helpers/goals_criteria_target_duration.php';
|
||||
|
||||
use classes\db;
|
||||
use goals\helpers\goals_criteria_target_duration;
|
||||
@@ -932,6 +933,282 @@ class goals_criteria implements goals_criteria_i
|
||||
];
|
||||
}
|
||||
|
||||
private function getAdvancedProgressDetailsForDepartment(
|
||||
int $departmentId,
|
||||
?string $timeframe,
|
||||
array $allDepartmentIds
|
||||
): array {
|
||||
$deptCriteria = $this->withDepartmentFilter($departmentId);
|
||||
if ($timeframe !== null) {
|
||||
$deptCriteria->setTimeframeByName($timeframe);
|
||||
}
|
||||
|
||||
$target = 0.0;
|
||||
if (($deptCriteria->start instanceof \DateTimeInterface) && ($deptCriteria->end instanceof \DateTimeInterface)) {
|
||||
$target = $this->calculateTargetForRange(
|
||||
$deptCriteria->start,
|
||||
$deptCriteria->end,
|
||||
$departmentId,
|
||||
$allDepartmentIds
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'count' => $deptCriteria->getProgress(),
|
||||
'target' => $target,
|
||||
'date_from' => ($deptCriteria->start instanceof \DateTimeInterface) ? $deptCriteria->start->format(DATE_ATOM) : null,
|
||||
'date_end' => ($deptCriteria->end instanceof \DateTimeInterface) ? $deptCriteria->end->format(DATE_ATOM) : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function calculateTargetForRange(
|
||||
\DateTimeInterface $windowStart,
|
||||
\DateTimeInterface $windowEnd,
|
||||
?int $departmentId = null,
|
||||
?array $departmentIdsForSplit = null
|
||||
): float {
|
||||
if (!$this->usesAdvancedTargetDuration()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$goalRange = $this->getGoalRange();
|
||||
if ($goalRange === null) {
|
||||
return 0.0;
|
||||
}
|
||||
[$goalStart, $goalEnd] = $goalRange;
|
||||
|
||||
$clampedRange = $this->clampRangeToGoal($windowStart, $windowEnd, $goalStart, $goalEnd);
|
||||
if ($clampedRange === null) {
|
||||
return 0.0;
|
||||
}
|
||||
[$rangeStart, $rangeEnd] = $clampedRange;
|
||||
|
||||
$totalTarget = $this->calculateAdvancedTotalTargetForRange($rangeStart, $rangeEnd, $goalStart, $goalEnd);
|
||||
if ($departmentId === null) {
|
||||
return $totalTarget;
|
||||
}
|
||||
|
||||
return $this->calculateDepartmentTargetShare($totalTarget, $departmentId, $departmentIdsForSplit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:\DateTimeImmutable,1:\DateTimeImmutable}|null
|
||||
*/
|
||||
private function getGoalRange(): ?array
|
||||
{
|
||||
if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface)) {
|
||||
return null;
|
||||
}
|
||||
if ($this->end < $this->start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timezone = $this->start->getTimezone();
|
||||
$goalStart = $this->toImmutable($this->start, $timezone);
|
||||
$goalEnd = $this->toImmutable($this->end, $timezone);
|
||||
if ($goalEnd < $goalStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$goalStart, $goalEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:\DateTimeImmutable,1:\DateTimeImmutable}|null
|
||||
*/
|
||||
private function clampRangeToGoal(
|
||||
\DateTimeInterface $windowStart,
|
||||
\DateTimeInterface $windowEnd,
|
||||
\DateTimeImmutable $goalStart,
|
||||
\DateTimeImmutable $goalEnd
|
||||
): ?array {
|
||||
$timezone = $goalStart->getTimezone();
|
||||
$start = $this->toImmutable($windowStart, $timezone);
|
||||
$end = $this->toImmutable($windowEnd, $timezone);
|
||||
if ($end < $start) {
|
||||
[$start, $end] = [$end, $start];
|
||||
}
|
||||
|
||||
if ($start < $goalStart) {
|
||||
$start = $goalStart;
|
||||
}
|
||||
if ($end > $goalEnd) {
|
||||
$end = $goalEnd;
|
||||
}
|
||||
|
||||
if ($end < $start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
private function toImmutable(\DateTimeInterface $date, ?\DateTimeZone $timezone = null): \DateTimeImmutable
|
||||
{
|
||||
$immutable = $date instanceof \DateTimeImmutable
|
||||
? $date
|
||||
: \DateTimeImmutable::createFromMutable($date);
|
||||
if ($timezone !== null) {
|
||||
$immutable = $immutable->setTimezone($timezone);
|
||||
}
|
||||
return $immutable;
|
||||
}
|
||||
|
||||
private function calculateAdvancedTotalTargetForRange(
|
||||
\DateTimeImmutable $rangeStart,
|
||||
\DateTimeImmutable $rangeEnd,
|
||||
\DateTimeImmutable $goalStart,
|
||||
\DateTimeImmutable $goalEnd
|
||||
): float {
|
||||
$baseTarget = max(0.0, (float)$this->target);
|
||||
if ($baseTarget <= 0.0 || !($this->target_duration instanceof goals_criteria_target_duration)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($this->target_duration === goals_criteria_target_duration::ENTIRE_DURATION) {
|
||||
$goalDays = $this->inclusiveDayCount($goalStart, $goalEnd);
|
||||
if ($goalDays <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
$overlapDays = $this->inclusiveDayCount($rangeStart, $rangeEnd);
|
||||
return $baseTarget * ($overlapDays / $goalDays);
|
||||
}
|
||||
|
||||
$every = max(1, (int)($this->target_duration_every ?? 1));
|
||||
$touchedBuckets = match ($this->target_duration) {
|
||||
goals_criteria_target_duration::WEEKS => $this->countTouchedIsoWeeks($rangeStart, $rangeEnd),
|
||||
goals_criteria_target_duration::MONTHS => $this->countTouchedCalendarMonths($rangeStart, $rangeEnd),
|
||||
goals_criteria_target_duration::YEARS => $this->countTouchedCalendarYears($rangeStart, $rangeEnd),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($touchedBuckets <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$intervals = (int)ceil($touchedBuckets / $every);
|
||||
return $baseTarget * $intervals;
|
||||
}
|
||||
|
||||
private function inclusiveDayCount(\DateTimeImmutable $start, \DateTimeImmutable $end): int
|
||||
{
|
||||
if ($end < $start) {
|
||||
return 0;
|
||||
}
|
||||
return (int)$start->diff($end)->format('%a') + 1;
|
||||
}
|
||||
|
||||
private function countTouchedIsoWeeks(\DateTimeImmutable $start, \DateTimeImmutable $end): int
|
||||
{
|
||||
$current = $start->setTime(0, 0, 0);
|
||||
$last = $end->setTime(0, 0, 0);
|
||||
$seen = [];
|
||||
while ($current <= $last) {
|
||||
$seen[$current->format('o-W')] = true;
|
||||
$current = $current->modify('+1 day');
|
||||
}
|
||||
return count($seen);
|
||||
}
|
||||
|
||||
private function countTouchedCalendarMonths(\DateTimeImmutable $start, \DateTimeImmutable $end): int
|
||||
{
|
||||
$current = $start->modify('first day of this month')->setTime(0, 0, 0);
|
||||
$last = $end->modify('first day of this month')->setTime(0, 0, 0);
|
||||
$count = 0;
|
||||
while ($current <= $last) {
|
||||
$count++;
|
||||
$current = $current->modify('+1 month');
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function countTouchedCalendarYears(\DateTimeImmutable $start, \DateTimeImmutable $end): int
|
||||
{
|
||||
$current = $start->setDate((int)$start->format('Y'), 1, 1)->setTime(0, 0, 0);
|
||||
$last = $end->setDate((int)$end->format('Y'), 1, 1)->setTime(0, 0, 0);
|
||||
$count = 0;
|
||||
while ($current <= $last) {
|
||||
$count++;
|
||||
$current = $current->modify('+1 year');
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function calculateDepartmentTargetShare(
|
||||
float $totalTarget,
|
||||
int $departmentId,
|
||||
?array $departmentIdsForSplit = null
|
||||
): float {
|
||||
if ($totalTarget <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$departmentIds = $this->normalizeDepartmentIds($departmentIdsForSplit ?? $this->departments->listIDs());
|
||||
if (empty($departmentIds) || !in_array($departmentId, $departmentIds, true)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$weights = $this->getDepartmentWeights($departmentIds);
|
||||
$weightSum = array_sum($weights);
|
||||
if ($weightSum <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return $totalTarget * (($weights[$departmentId] ?? 0.0) / $weightSum);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $departmentIds
|
||||
* @return array<int,float>
|
||||
*/
|
||||
private function getDepartmentWeights(array $departmentIds): array
|
||||
{
|
||||
$weights = [];
|
||||
$hasOverrides = false;
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
if (isset($this->department_daily_targets[$departmentId]) || isset($this->department_weekly_targets[$departmentId])) {
|
||||
$hasOverrides = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasOverrides) {
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
if (isset($this->department_daily_targets[$departmentId])) {
|
||||
$weights[$departmentId] = max(0.0, (float)$this->department_daily_targets[$departmentId]);
|
||||
continue;
|
||||
}
|
||||
if (isset($this->department_weekly_targets[$departmentId])) {
|
||||
$weights[$departmentId] = max(0.0, (float)$this->department_weekly_targets[$departmentId]);
|
||||
continue;
|
||||
}
|
||||
$weights[$departmentId] = 0.0;
|
||||
}
|
||||
if (array_sum($weights) > 0.0) {
|
||||
return $weights;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$weights[$departmentId] = 1.0;
|
||||
}
|
||||
return $weights;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $departmentIds
|
||||
* @return int[]
|
||||
*/
|
||||
private function normalizeDepartmentIds(array $departmentIds): array
|
||||
{
|
||||
$normalized = array_values(array_unique(array_filter(
|
||||
array_map('intval', $departmentIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function getTimeframeDayCount(): int
|
||||
{
|
||||
if (!($this->start instanceof \DateTimeInterface) || !($this->end instanceof \DateTimeInterface)) {
|
||||
|
||||
@@ -160,6 +160,8 @@ class goals_progress_alert_renderer
|
||||
'highlight' => ($department && isset($department->id) && (int)$department->id === $deptId)
|
||||
];
|
||||
}
|
||||
$allDepartmentIds = array_map(static fn(array $deptData): int => (int)$deptData['id'], $departments);
|
||||
$isAdvancedDuration = method_exists($criteria, 'usesAdvancedTargetDuration') && $criteria->usesAdvancedTargetDuration();
|
||||
// Build the output lines
|
||||
$outLines = [];
|
||||
// Prepend header lines if provided
|
||||
@@ -247,16 +249,31 @@ class goals_progress_alert_renderer
|
||||
$total_period_count += $count;
|
||||
|
||||
if ($includeTargets && isset($criteria->target)) {
|
||||
if ($isMonthPeriod) {
|
||||
$target = self::getMonthlyTargetForDepartment($criteria, (int)$deptId, $departments);
|
||||
if ($isAdvancedDuration) {
|
||||
if ($range !== null) {
|
||||
$target = (int)round($criteria->calculateTargetForRange(
|
||||
\DateTime::createFromImmutable($rs),
|
||||
\DateTime::createFromImmutable($re),
|
||||
(int)$deptId,
|
||||
$allDepartmentIds
|
||||
));
|
||||
} else {
|
||||
$target = 0;
|
||||
}
|
||||
} else {
|
||||
// Set the target to (operating days in period) * (daily target)
|
||||
$target = self::getTargetForDepartmentInTimeframe(
|
||||
$criteria,
|
||||
$rs,
|
||||
$re,
|
||||
(int)$deptId
|
||||
);
|
||||
if ($isMonthPeriod) {
|
||||
$target = self::getMonthlyTargetForDepartment($criteria, (int)$deptId, $departments);
|
||||
} elseif ($range === null) {
|
||||
$target = 0;
|
||||
} else {
|
||||
// Set the target to (operating days in period) * (daily target)
|
||||
$target = self::getTargetForDepartmentInTimeframe(
|
||||
$criteria,
|
||||
$rs,
|
||||
$re,
|
||||
(int)$deptId
|
||||
);
|
||||
}
|
||||
}
|
||||
$percent = $target > 0 ? round(($count / max(1, $target)) * 100, 2) : 0.0;
|
||||
// If the $department is set and matches the current department, make the related line stand out (*text here*)
|
||||
@@ -273,18 +290,33 @@ class goals_progress_alert_renderer
|
||||
}
|
||||
// Total line for the period
|
||||
if ($includeTargets && isset($criteria->target)) {
|
||||
// Sum per-department targets to account for overrides
|
||||
if ($isMonthPeriod) {
|
||||
$total_target = max(0, (int)round((float)($criteria->target ?? 0)));
|
||||
if ($isAdvancedDuration) {
|
||||
if ($range !== null) {
|
||||
$total_target = (int)round($criteria->calculateTargetForRange(
|
||||
\DateTime::createFromImmutable($rs),
|
||||
\DateTime::createFromImmutable($re),
|
||||
null,
|
||||
$allDepartmentIds
|
||||
));
|
||||
} else {
|
||||
$total_target = 0;
|
||||
}
|
||||
} else {
|
||||
$total_target = 0;
|
||||
foreach ($departments as $deptData) {
|
||||
$total_target += self::getTargetForDepartmentInTimeframe(
|
||||
$criteria,
|
||||
$rs,
|
||||
$re,
|
||||
(int)$deptData['id']
|
||||
);
|
||||
// Sum per-department targets to account for overrides
|
||||
if ($isMonthPeriod) {
|
||||
$total_target = max(0, (int)round((float)($criteria->target ?? 0)));
|
||||
} elseif ($range === null) {
|
||||
$total_target = 0;
|
||||
} else {
|
||||
$total_target = 0;
|
||||
foreach ($departments as $deptData) {
|
||||
$total_target += self::getTargetForDepartmentInTimeframe(
|
||||
$criteria,
|
||||
$rs,
|
||||
$re,
|
||||
(int)$deptData['id']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
$total_percent = $total_target > 0 ? round(($total_period_count / max(1, $total_target)) * 100, 2) : 0.0;
|
||||
|
||||
@@ -20,10 +20,10 @@ class workfeed_api_url_c
|
||||
'string',
|
||||
true,
|
||||
null,
|
||||
'The base URL for the Workfeed API',
|
||||
'https://api.workfeed.io',
|
||||
'The base URL for the Workfeed API (see docs.workfeed.io)',
|
||||
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api',
|
||||
false,
|
||||
'https://api.workfeed.io'
|
||||
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user