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:
Jeppe Bundgaard
2026-03-24 11:35:13 +01:00
parent 8c9388546b
commit 28b571e2e6
13 changed files with 877 additions and 332 deletions
@@ -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'
);
}
}