Add goals feature for personal goals with filters

This commit is contained in:
Gregor Vostrak
2026-10-02 12:59:44 +02:00
parent 5010a5be3a
commit 38e33b318e
83 changed files with 7702 additions and 105 deletions

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\GoalType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Http\Requests\V1\Goal\GoalIndexRequest;
use App\Http\Requests\V1\Goal\GoalStoreRequest;
use App\Http\Requests\V1\Goal\GoalUpdateRequest;
use App\Http\Resources\V1\Goal\GoalCollection;
use App\Http\Resources\V1\Goal\GoalResource;
use App\Models\Goal;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\GoalsContract;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class GoalController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Goal $goal = null): void
{
parent::checkPermission($organization, $permission);
if ($goal !== null) {
$this->checkGoalBelongsToOrganization($organization, $goal);
}
}
/**
* @throws AuthorizationException
*/
private function checkGoalBelongsToOrganization(Organization $organization, Goal $goal): void
{
if ($goal->organization_id !== $organization->id) {
throw new AuthorizationException('Goal does not belong to organization');
}
}
/**
* Enforces the goal limit provided by the BillingContract, if any. Archived goals count toward the limit.
*
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
private function checkGoalLimit(Organization $organization): void
{
$limit = app(BillingContract::class)->getGoalLimit($organization);
if ($limit === null) {
return;
}
$goals = Goal::query()
->whereBelongsTo($organization, 'organization')
->count();
if ($goals >= $limit) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
}
/**
* Get goals
*
* Returns the goals the current member is allowed to see, including the progress in the current period.
*
* @return GoalCollection<GoalResource>
*
* @throws AuthorizationException
*
* @operationId getGoals
*/
public function index(Organization $organization, GoalIndexRequest $request, GoalsContract $access): GoalCollection
{
// An organization goal is visible to the member it is for, who does not hold the :organization-type permission.
// Either permission is enough, the access contract decides which goals come back.
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$member = $this->member($organization);
$query = Goal::query()
->whereBelongsTo($organization, 'organization')
->with(['member.user'])
->orderBy('created_at', 'desc')
->orderBy('id');
$query = $access->scopeVisibleGoals($query, $member);
if ($request->getType() !== null) {
$query->where('type', '=', $request->getType()->value);
}
if ($request->getArchivedFilter() === 'true') {
$query->archived();
} elseif ($request->getArchivedFilter() === 'false') {
$query->notArchived();
}
$goals = $query->paginate(config('app.pagination_per_page_default'));
return new GoalCollection($goals);
}
/**
* Get goal
*
* @throws AuthorizationException
*
* @operationId getGoal
*/
public function show(Organization $organization, Goal $goal, GoalsContract $access): GoalResource
{
// Either permission is enough, see index
$this->checkAnyPermission($organization, ['goals:view:own', 'goals:view:organization-type']);
$this->checkGoalBelongsToOrganization($organization, $goal);
$member = $this->member($organization);
if (! $access->canViewGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->load('member.user');
return new GoalResource($goal);
}
/**
* Create goal
*
* @throws AuthorizationException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId createGoal
*/
public function store(Organization $organization, GoalStoreRequest $request, GoalsContract $access): JsonResponse
{
if ($request->getType() === GoalType::Personal) {
$this->checkPermission($organization, 'goals:create:own');
} else {
$this->checkPermission($organization, 'goals:create:organization-type');
}
$member = $this->member($organization);
$type = $request->getType();
$targetMemberId = $request->hasMemberId() ? $request->getMemberId() : $member->getKey();
if (! $access->canCreateGoal($member, $type, $targetMemberId)) {
throw new AuthorizationException;
}
$this->checkGoalLimit($organization);
$user = $member->user;
$goal = new Goal;
$goal->name = $request->getName();
$goal->type = $type;
$goal->comparison = $request->getComparison();
$goal->target_seconds = $request->getTargetSeconds();
$goal->period = $request->getPeriod();
$goal->filters = $request->getFilters();
$goal->timezone = $request->getTimezone() ?? app(TimezoneService::class)->getTimezoneFromUser($user)->getName();
$goal->week_start = $request->getWeekStart() ?? $user->week_start;
$goal->organization()->associate($organization);
$goal->member_id = $targetMemberId;
$goal->save();
$goal->load('member.user');
return (new GoalResource($goal))
->response()
->setStatusCode(201);
}
/**
* Update goal
*
* The type of a goal and the member it is for can not be changed after creation.
*
* @throws AuthorizationException
*
* @operationId updateGoal
*/
public function update(Organization $organization, Goal $goal, GoalUpdateRequest $request, GoalsContract $access): GoalResource
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:update:own', $goal);
} else {
$this->checkPermission($organization, 'goals:update:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canUpdateGoal($member, $goal)) {
throw new AuthorizationException;
}
if ($request->has('filters')) {
$goal->filters = $request->getFilters();
}
if ($request->has('name')) {
$goal->name = $request->getName();
}
if ($request->has('comparison')) {
$goal->comparison = $request->getComparison();
}
if ($request->has('target_seconds')) {
$goal->target_seconds = $request->getTargetSeconds();
}
if ($request->has('period')) {
$goal->period = $request->getPeriod();
}
if ($request->has('timezone')) {
$goal->timezone = $request->getTimezone();
}
if ($request->has('week_start')) {
$goal->week_start = $request->getWeekStart();
}
if ($request->has('is_archived')) {
$goal->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$goal->save();
$goal->load('member.user');
return new GoalResource($goal);
}
/**
* Delete goal
*
* @throws AuthorizationException
*
* @operationId deleteGoal
*/
public function destroy(Organization $organization, Goal $goal, GoalsContract $access): JsonResponse
{
if ($goal->type === GoalType::Personal) {
$this->checkPermission($organization, 'goals:delete:own', $goal);
} else {
$this->checkPermission($organization, 'goals:delete:organization-type', $goal);
}
$member = $this->member($organization);
if (! $access->canDeleteGoal($member, $goal)) {
throw new AuthorizationException;
}
$goal->delete();
return response()->json(null, 204);
}
}

View File

@@ -12,7 +12,7 @@ class TimeZoneController extends Controller
/**
* Get all timezones
*
* @response object{key: string}[]
* @response array{key: string}[]
*
* @operationId getTimezones
*/

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Weekday;
use App\Service\Dto\UserAgentDto;
use App\Service\TimezoneService;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
@@ -94,8 +92,6 @@ class UserProfileController extends Controller
$this->validateTwoFactorAuthenticationState($request);
return Inertia::render('Profile/Show', [
'timezones' => app(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
'sessions' => $this->sessions($request),
]);

View File

@@ -42,6 +42,7 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
$hasGoals = Module::has('Goals') && Module::isEnabled('Goals');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -52,11 +53,13 @@ class HandleInertiaRequests extends Middleware
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'has_services_extension' => $hasServices,
'has_goals_extension' => $hasGoals,
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\Dto\GoalFiltersDto;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* Shared validation rules for the filters of a goal.
*
* @property Organization $organization Organization from model binding
*/
trait GoalFilterRules
{
/**
* @param bool $forEveryMember Whether the goal counts every member instead of one member
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
protected function filterRules(bool $forEveryMember): array
{
return [
'filters' => [
'sometimes',
'array',
],
// Filter by member IDs, member IDs are OR combined. Only for goals that count every member, null means every member.
'filters.member_ids' => [
'nullable',
'array',
// "prohibited" still lets null and an empty array through, both mean "no restriction"
...($forEveryMember ? [] : ['prohibited']),
],
'filters.member_ids.*' => [
'string',
'distinct',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined, "none" matches entries without a project
'filters.project_ids' => [
'nullable',
'array',
],
'filters.project_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Project::class),
],
// Filter by task IDs, task IDs are OR combined, "none" matches entries without a task
'filters.task_ids' => [
'nullable',
'array',
],
'filters.task_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Task::class),
],
// Filter by tag IDs, tag IDs are OR combined, "none" matches entries without tags
'filters.tag_ids' => [
'nullable',
'array',
],
'filters.tag_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Tag::class),
],
'filters.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
// Filter by client IDs, client IDs are OR combined, "none" matches entries without a client
'filters.client_ids' => [
'nullable',
'array',
],
'filters.client_ids.*' => [
'string',
$this->idOrNoneExistsInOrganization(Client::class),
],
// Filter by billable status, null means both
'filters.billable' => [
'nullable',
'boolean',
],
// Filter by time entry type, null means both
'filters.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
/**
* @param class-string<Project|Task|Tag|Client> $modelClass
*/
private function idOrNoneExistsInOrganization(string $modelClass): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail) use ($modelClass): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
ExistsEloquent::make($modelClass, null, function (Builder $builder): Builder {
/** @var Builder<Project|Task|Tag|Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid()->validate($attribute, $value, $fail);
};
}
public function getFilters(): GoalFiltersDto
{
$filters = new GoalFiltersDto;
$filters->setMemberIds($this->input('filters.member_ids'));
$filters->setProjectIds($this->input('filters.project_ids'));
$filters->setTaskIds($this->input('filters.task_ids'));
$filters->setTagIds($this->input('filters.tag_ids'));
$filters->tagMatchType = $this->input('filters.tag_match_type') !== null ? TagMatchType::from($this->input('filters.tag_match_type')) : null;
$filters->setClientIds($this->input('filters.client_ids'));
$filters->billable = $this->input('filters.billable') !== null ? (bool) $this->input('filters.billable') : null;
$filters->timeEntryType = $this->input('filters.time_entry_type') !== null ? TimeEntryType::from($this->input('filters.time_entry_type')) : null;
return $filters;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class GoalIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
return [
// Only return goals of this type (personal, organization)
'type' => [
'nullable',
'string',
Rule::enum(GoalType::class),
],
// Filter by archived status, "true" only archived, "false" only not archived (default), "all" both
'archived' => [
'nullable',
'string',
'in:true,false,all',
],
'page' => [
'nullable',
'integer',
'min:1',
'max:2147483647',
],
];
}
public function getType(): ?GoalType
{
if ($this->input('type') === null) {
return null;
}
return GoalType::from($this->input('type'));
}
public function getArchivedFilter(): string
{
return (string) $this->input('archived', 'false');
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\GoalType;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class GoalStoreRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|In|\Closure>>
*/
public function rules(): array
{
$memberExistsInOrganization = ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization')
->where('role', '!=', Role::Placeholder->value);
})->uuid();
return array_merge([
'name' => [
'required',
'string',
'max:255',
],
// personal: a goal for yourself that nobody else can see, organization: a goal of the organization for a member or for every member. Organization goals require the team goals extension.
'type' => [
'required',
'string',
Rule::enum(GoalType::class),
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'required',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'required',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'required',
'string',
Rule::enum(GoalPeriod::class),
],
// ID of the member whose time entries count towards the goal, defaults to the current member. Send null for a goal that counts every member (organization goals only).
'member_id' => $this->input('type') === GoalType::Personal->value
// A personal goal is always for the current member, it can not count every member
? [
'sometimes',
'filled',
'string',
Rule::in([$this->currentMemberId()]),
]
: [
'nullable',
'string',
$memberExistsInOrganization,
],
// Timezone that defines the periods of the goal, defaults to the timezone of the current user
'timezone' => [
'nullable',
'timezone:all',
],
// Week start that defines weekly periods, defaults to the week start of the current user
'week_start' => [
'nullable',
'string',
Rule::enum(Weekday::class),
],
], $this->filterRules(
// Without a member_id the goal is for the current member
$this->has('member_id') && $this->input('member_id') === null,
));
}
private function currentMemberId(): ?string
{
/** @var string|null $memberId */
$memberId = Member::query()
->whereBelongsTo($this->organization, 'organization')
->where('user_id', $this->user()?->getKey())
->value('id');
return $memberId;
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getType(): GoalType
{
return GoalType::from($this->input('type'));
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function hasMemberId(): bool
{
return $this->has('member_id');
}
public function getMemberId(): ?string
{
return $this->input('member_id');
}
public function getTimezone(): ?string
{
if (! $this->has('timezone') || $this->input('timezone') === null) {
return null;
}
return (string) $this->input('timezone');
}
public function getWeekStart(): ?Weekday
{
if (! $this->has('week_start') || $this->input('week_start') === null) {
return null;
}
return Weekday::from($this->input('week_start'));
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Goal;
use App\Enums\GoalComparison;
use App\Enums\GoalPeriod;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Goal;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class GoalUpdateRequest extends BaseFormRequest
{
use GoalFilterRules;
/**
* Get the validation rules that apply to the request.
* The type of a goal and the member it is for can not be changed after creation.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
/** @var Goal $goal */
$goal = $this->route('goal');
return array_merge([
'name' => [
'sometimes',
'string',
'max:255',
],
// Whether the goal is reached when "at least" or "less than" the target time is tracked in the period
'comparison' => [
'sometimes',
'string',
Rule::enum(GoalComparison::class),
],
// Target time in seconds
'target_seconds' => [
'sometimes',
'integer',
'min:1',
'max:2147483647',
],
// The recurring time frame in which the target has to be reached
'period' => [
'sometimes',
'string',
Rule::enum(GoalPeriod::class),
],
// Timezone that defines the periods of the goal
'timezone' => [
'sometimes',
'timezone:all',
],
// Week start that defines weekly periods
'week_start' => [
'sometimes',
'string',
Rule::enum(Weekday::class),
],
// Archived goals are hidden from the goal list by default, their progress is still calculated
'is_archived' => [
'sometimes',
'boolean',
],
// Only allowed if it matches the current type of the goal
'type' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->type->value) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
// Only allowed if it matches the current member of the goal
'member_id' => [
function (string $attribute, mixed $value, \Closure $fail) use ($goal): void {
if ($value !== $goal->member_id) {
$fail('The '.$attribute.' of a goal can not be changed.');
}
},
],
], $this->filterRules($goal->member_id === null));
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getComparison(): GoalComparison
{
return GoalComparison::from($this->input('comparison'));
}
public function getTargetSeconds(): int
{
return (int) $this->input('target_seconds');
}
public function getPeriod(): GoalPeriod
{
return GoalPeriod::from($this->input('period'));
}
public function getTimezone(): string
{
return (string) $this->input('timezone');
}
public function getWeekStart(): Weekday
{
return Weekday::from($this->input('week_start'));
}
public function getIsArchived(): bool
{
return (bool) $this->input('is_archived');
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class GoalCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = GoalResource::class;
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Goal;
use App\Http\Resources\V1\BaseResource;
use App\Models\Goal;
use App\Service\GoalProgressService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
/**
* @property Goal $resource
*/
class GoalResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
$progressService = app(GoalProgressService::class);
$now = Carbon::now();
[$periodStart, $periodEnd] = $progressService->getPeriodBounds($this->resource, $now);
$trackedSeconds = $progressService->getProgress($this->resource, $periodStart, $periodEnd);
return [
/** @var string $id ID of the goal */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $type personal: a goal a member set for themselves, organization: a goal of the organization (team goals extension) */
'type' => $this->resource->type->value,
/** @var string $comparison Whether the goal is reached with "at least" or "less than" the target time (at_least, less_than) */
'comparison' => $this->resource->comparison->value,
/** @var int $target_seconds Target time in seconds */
'target_seconds' => $this->resource->target_seconds,
/** @var string $period Recurring time frame (day, week, month) */
'period' => $this->resource->period->value,
/** @var string|null $member_id ID of the member whose time entries count towards the goal, null if every member counts */
'member_id' => $this->resource->member_id,
/** @var string|null $member_name Name of the member whose time entries count towards the goal, null if every member counts */
'member_name' => $this->resource->member?->user->name,
/** @var string $timezone Timezone that defines the periods of the goal */
'timezone' => $this->resource->timezone,
/** @var string $week_start Week start that defines weekly periods */
'week_start' => $this->resource->week_start->value,
/** @var bool $is_archived Whether the goal is archived */
'is_archived' => $this->resource->is_archived,
'filters' => [
/** @var array<string>|null $member_ids Filter by member IDs, member IDs are OR combined, only for goals that count every member */
'member_ids' => $this->resource->filters->memberIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->filters->projectIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->filters->taskIds?->toArray(),
/** @var array<string>|null $tag_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->filters->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type (contains, not_contains) */
'tag_match_type' => $this->resource->filters->tagMatchType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->filters->clientIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->filters->billable,
/** @var string|null $time_entry_type Filter by time entry type (work, break) */
'time_entry_type' => $this->resource->filters->timeEntryType?->value,
],
'progress' => [
/** @var string $period_start Start of the current period (inclusive) */
'period_start' => $this->formatDateTime($periodStart),
/** @var string $period_end End of the current period (exclusive) */
'period_end' => $this->formatDateTime($periodEnd),
/** @var int $tracked_seconds Seconds tracked in the current period that match the filters, incl. the running time entry */
'tracked_seconds' => $trackedSeconds,
/** @var string $status Status in the current period (in_progress, achieved, on_track, exceeded) */
'status' => $progressService->getStatus($this->resource, $trackedSeconds)->value,
],
/** @var string $created_at Date when the goal was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the goal was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}
}