diff --git a/app/Enums/GoalComparison.php b/app/Enums/GoalComparison.php new file mode 100644 index 00000000..e5b824cd --- /dev/null +++ b/app/Enums/GoalComparison.php @@ -0,0 +1,22 @@ +columns(1) + ->components([ + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Forms\Components\Select::make('type') + ->label('Type') + ->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all()) + ->disabled() + ->required(), + Forms\Components\Select::make('comparison') + ->label('Comparison') + ->options(collect(GoalComparison::cases())->mapWithKeys(fn (GoalComparison $comparison): array => [$comparison->value => $comparison->name])->all()) + ->required(), + Forms\Components\TextInput::make('target_seconds') + ->label('Target (seconds)') + ->numeric() + ->minValue(1) + ->required(), + Forms\Components\Select::make('period') + ->label('Period') + ->options(collect(GoalPeriod::cases())->mapWithKeys(fn (GoalPeriod $period): array => [$period->value => $period->name])->all()) + ->required(), + Forms\Components\Select::make('timezone') + ->label('Timezone') + ->options(fn (): array => collect(\DateTimeZone::listIdentifiers())->mapWithKeys(fn (string $timezone): array => [$timezone => $timezone])->all()) + ->searchable() + ->required(), + Forms\Components\Select::make('week_start') + ->label('Week start') + ->options(collect(Weekday::cases())->mapWithKeys(fn (Weekday $weekday): array => [$weekday->value => $weekday->name])->all()) + ->required(), + DateTimePicker::make('archived_at') + ->label('Archived At'), + Forms\Components\Select::make('organization_id') + ->label('Organization') + ->relationship(name: 'organization', titleAttribute: 'name') + ->searchable(['name']) + ->disabled() + ->required(), + Forms\Components\Select::make('member_id') + ->label('Member (empty = every member)') + ->relationship(name: 'member', titleAttribute: 'id') + ->disabled(), + PrettyJsonField::make('filters') + ->formatStateUsing(function (GoalFiltersDto $state, Goal $record): string { + return $record->getRawOriginal('filters'); + }) + ->disabled(), + DateTimePicker::make('created_at') + ->label('Created At') + ->hiddenOn(['create']) + ->disabled(), + DateTimePicker::make('updated_at') + ->label('Updated At') + ->hiddenOn(['create']) + ->disabled(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('comparison') + ->sortable(), + TextColumn::make('target_seconds') + ->label('Target (seconds)') + ->sortable(), + TextColumn::make('period') + ->sortable(), + TextColumn::make('type') + ->sortable(), + TextColumn::make('member.user.email') + ->label('Member') + ->placeholder('Every member') + ->searchable(), + TextColumn::make('archived_at') + ->dateTime() + ->placeholder('Not archived') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('organization.name') + ->searchable() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable(), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('type') + ->label('Type') + ->options(collect(GoalType::cases())->mapWithKeys(fn (GoalType $type): array => [$type->value => $type->name])->all()), + SelectFilter::make('organization') + ->label('Organization') + ->relationship('organization', 'name') + ->searchable(), + SelectFilter::make('organization_id') + ->label('Organization ID') + ->relationship('organization', 'id') + ->searchable(), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + ]); + } + + public static function getRelations(): array + { + return [ + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListGoals::route('/'), + 'edit' => Pages\EditGoal::route('/{record}/edit'), + 'view' => Pages\ViewGoal::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/GoalResource/Pages/EditGoal.php b/app/Filament/Resources/GoalResource/Pages/EditGoal.php new file mode 100644 index 00000000..3ba64ad3 --- /dev/null +++ b/app/Filament/Resources/GoalResource/Pages/EditGoal.php @@ -0,0 +1,22 @@ +icon('heroicon-m-trash'), + ]; + } +} diff --git a/app/Filament/Resources/GoalResource/Pages/ListGoals.php b/app/Filament/Resources/GoalResource/Pages/ListGoals.php new file mode 100644 index 00000000..2be14a30 --- /dev/null +++ b/app/Filament/Resources/GoalResource/Pages/ListGoals.php @@ -0,0 +1,19 @@ +icon('heroicon-s-pencil'), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/GoalController.php b/app/Http/Controllers/Api/V1/GoalController.php new file mode 100644 index 00000000..981a5a9a --- /dev/null +++ b/app/Http/Controllers/Api/V1/GoalController.php @@ -0,0 +1,241 @@ +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 + * + * @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); + } +} diff --git a/app/Http/Controllers/Api/V1/TimeZoneController.php b/app/Http/Controllers/Api/V1/TimeZoneController.php index d921e269..6522cf05 100644 --- a/app/Http/Controllers/Api/V1/TimeZoneController.php +++ b/app/Http/Controllers/Api/V1/TimeZoneController.php @@ -12,7 +12,7 @@ class TimeZoneController extends Controller /** * Get all timezones * - * @response object{key: string}[] + * @response array{key: string}[] * * @operationId getTimezones */ diff --git a/app/Http/Controllers/Web/UserProfileController.php b/app/Http/Controllers/Web/UserProfileController.php index 4cb74747..1ac93690 100644 --- a/app/Http/Controllers/Web/UserProfileController.php +++ b/app/Http/Controllers/Web/UserProfileController.php @@ -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), ]); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a27bacf8..d9c26387 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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'), diff --git a/app/Http/Requests/V1/Goal/GoalFilterRules.php b/app/Http/Requests/V1/Goal/GoalFilterRules.php new file mode 100644 index 00000000..44fc6dd0 --- /dev/null +++ b/app/Http/Requests/V1/Goal/GoalFilterRules.php @@ -0,0 +1,143 @@ +> + */ + 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 $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 $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 $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; + } +} diff --git a/app/Http/Requests/V1/Goal/GoalIndexRequest.php b/app/Http/Requests/V1/Goal/GoalIndexRequest.php new file mode 100644 index 00000000..18254bbd --- /dev/null +++ b/app/Http/Requests/V1/Goal/GoalIndexRequest.php @@ -0,0 +1,61 @@ +> + */ + 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'); + } +} diff --git a/app/Http/Requests/V1/Goal/GoalStoreRequest.php b/app/Http/Requests/V1/Goal/GoalStoreRequest.php new file mode 100644 index 00000000..a6e9ba71 --- /dev/null +++ b/app/Http/Requests/V1/Goal/GoalStoreRequest.php @@ -0,0 +1,167 @@ +> + */ + public function rules(): array + { + $memberExistsInOrganization = ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $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')); + } +} diff --git a/app/Http/Requests/V1/Goal/GoalUpdateRequest.php b/app/Http/Requests/V1/Goal/GoalUpdateRequest.php new file mode 100644 index 00000000..66de1775 --- /dev/null +++ b/app/Http/Requests/V1/Goal/GoalUpdateRequest.php @@ -0,0 +1,129 @@ +> + */ + 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'); + } +} diff --git a/app/Http/Resources/V1/Goal/GoalCollection.php b/app/Http/Resources/V1/Goal/GoalCollection.php new file mode 100644 index 00000000..80b3abad --- /dev/null +++ b/app/Http/Resources/V1/Goal/GoalCollection.php @@ -0,0 +1,18 @@ +>> + */ + 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|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|null $project_ids Filter by project IDs, project IDs are OR combined */ + 'project_ids' => $this->resource->filters->projectIds?->toArray(), + /** @var array|null $task_ids Filter by task IDs, task IDs are OR combined */ + 'task_ids' => $this->resource->filters->taskIds?->toArray(), + /** @var array|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|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), + ]; + } +} diff --git a/app/Models/Goal.php b/app/Models/Goal.php new file mode 100644 index 00000000..7a90518b --- /dev/null +++ b/app/Models/Goal.php @@ -0,0 +1,114 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'type' => GoalType::class, + 'comparison' => GoalComparison::class, + 'target_seconds' => 'int', + 'period' => GoalPeriod::class, + 'filters' => GoalFiltersDto::class, + 'week_start' => Weekday::class, + 'archived_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + /** + * The member whose time entries count towards the goal, null for goals that count every member. + * + * @return BelongsTo + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + + public function isPersonalGoalOf(Member $member): bool + { + return $this->type === GoalType::Personal && $this->member_id === $member->getKey(); + } + + /** + * @return Attribute + */ + protected function isArchived(): Attribute + { + return Attribute::make( + get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']), + ); + } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeArchived(Builder $builder): Builder + { + return $builder->whereNotNull('archived_at'); + } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeNotArchived(Builder $builder): Builder + { + return $builder->whereNull('archived_at'); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php index e8af33e4..7cc3fefc 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -28,6 +28,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property-read User $user * @property-read Collection $projectMembers * @property-read Collection $timeEntries + * @property-read Collection $goals * * @method static MemberFactory factory() */ @@ -78,4 +79,14 @@ class Member extends Pivot implements AuditableContract { return $this->hasMany(ProjectMember::class, 'member_id'); } + + /** + * Goals whose progress counts the time entries of this member. + * + * @return HasMany + */ + public function goals(): HasMany + { + return $this->hasMany(Goal::class, 'member_id'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2149b359..1a767982 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; use App\Service\BillingContract; +use App\Service\GoalsContract; use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\NoIpLookupService; use App\Service\PermissionStore; @@ -103,6 +104,7 @@ class AppServiceProvider extends ServiceProvider // Extensions $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(BillingContract::class); + $this->app->bind(GoalsContract::class); // Storage // The local driver ignores the ResponseContentDisposition option of temporaryUrl, diff --git a/app/Service/BillingContract.php b/app/Service/BillingContract.php index a3c58a3b..40d0a002 100644 --- a/app/Service/BillingContract.php +++ b/app/Service/BillingContract.php @@ -43,6 +43,15 @@ class BillingContract return null; } + /** + * The maximum number of goals the organization can have; null means unlimited. + * Archived goals count toward the limit. + */ + public function getGoalLimit(Organization $organization): ?int + { + return null; + } + /** * Check if the organization is blocked * A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial diff --git a/app/Service/DeletionService.php b/app/Service/DeletionService.php index 872e4996..493b474b 100644 --- a/app/Service/DeletionService.php +++ b/app/Service/DeletionService.php @@ -8,6 +8,7 @@ use App\Enums\Role; use App\Events\BeforeOrganizationDeletion; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Models\Client; +use App\Models\Goal; use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; @@ -76,6 +77,9 @@ class DeletionService // Delete all reports Report::query()->whereBelongsTo($organization, 'organization')->delete(); + // Delete all goals + Goal::query()->whereBelongsTo($organization, 'organization')->delete(); + // Reset the current organization $organization->owner() ->where('current_team_id', $organization->getKey()) diff --git a/app/Service/Dto/GoalFiltersDto.php b/app/Service/Dto/GoalFiltersDto.php new file mode 100644 index 00000000..523ed292 --- /dev/null +++ b/app/Service/Dto/GoalFiltersDto.php @@ -0,0 +1,169 @@ +|null + */ + public ?Collection $memberIds = null; + + /** + * @var Collection|null + */ + public ?Collection $projectIds = null; + + /** + * @var Collection|null + */ + public ?Collection $taskIds = null; + + /** + * @var Collection|null + */ + public ?Collection $tagIds = null; + + public ?TagMatchType $tagMatchType = null; + + /** + * @var Collection|null + */ + public ?Collection $clientIds = null; + + public ?bool $billable = null; + + public ?TimeEntryType $timeEntryType = null; + + /** + * Get the caster class to use when casting from / to this cast target. + * + * @param array $arguments + * @return CastsAttributes + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes + { + private const array REQUIRED_PROPERTIES = [ + 'memberIds', + 'projectIds', + 'taskIds', + 'tagIds', + 'tagMatchType', + 'clientIds', + 'billable', + 'timeEntryType', + ]; + + public function get(Model $model, string $key, mixed $value, array $attributes): GoalFiltersDto + { + if (! is_string($value)) { + throw new \InvalidArgumentException('The given value is not a string'); + } + $data = json_decode($value, false); + if (! is_object($data)) { + throw new \InvalidArgumentException('The given value is not a JSON object string'); + } + foreach (self::REQUIRED_PROPERTIES as $property) { + if (! property_exists($data, $property)) { + throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"'); + } + } + $dto = new GoalFiltersDto; + $dto->setMemberIds(isset($data->memberIds) ? (array) $data->memberIds : null); + $dto->setProjectIds(isset($data->projectIds) ? (array) $data->projectIds : null); + $dto->setTaskIds(isset($data->taskIds) ? (array) $data->taskIds : null); + $dto->setTagIds(isset($data->tagIds) ? (array) $data->tagIds : null); + $dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null; + $dto->setClientIds(isset($data->clientIds) ? (array) $data->clientIds : null); + $dto->billable = isset($data->billable) ? (bool) $data->billable : null; + $dto->timeEntryType = isset($data->timeEntryType) ? TimeEntryType::from($data->timeEntryType) : null; + + return $dto; + } + + public function set(Model $model, string $key, mixed $value, array $attributes): string + { + if (! ($value instanceof GoalFiltersDto)) { + throw new \InvalidArgumentException('The given value is not an instance of GoalFiltersDto'); + } + + $data = (object) [ + 'memberIds' => $value->memberIds?->toArray(), + 'projectIds' => $value->projectIds?->toArray(), + 'taskIds' => $value->taskIds?->toArray(), + 'tagIds' => $value->tagIds?->toArray(), + 'tagMatchType' => $value->tagMatchType?->value, + 'clientIds' => $value->clientIds?->toArray(), + 'billable' => $value->billable, + 'timeEntryType' => $value->timeEntryType?->value, + ]; + + $jsonString = json_encode($data); + if ($jsonString === false) { + throw new \InvalidArgumentException('Could not encode the given data to a JSON string'); + } + + return $jsonString; + } + }; + } + + /** + * @param array|null $memberIds + */ + public function setMemberIds(?array $memberIds): void + { + $this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null; + } + + /** + * @param array|null $projectIds + */ + public function setProjectIds(?array $projectIds): void + { + $this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null; + } + + /** + * @param array|null $taskIds + */ + public function setTaskIds(?array $taskIds): void + { + $this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null; + } + + /** + * @param array|null $tagIds + */ + public function setTagIds(?array $tagIds): void + { + $this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null; + } + + /** + * @param array|null $clientIds + */ + public function setClientIds(?array $clientIds): void + { + $this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null; + } +} diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 6f4ea450..83270978 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -16,7 +16,6 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use InvalidArgumentException; class ReportPropertiesDto implements Castable @@ -117,13 +116,13 @@ class ReportPropertiesDto implements Castable $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null; $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null; $dto->active = $data->active; - $dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null; + $dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null; $dto->billable = $data->billable; - $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; - $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; - $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; + $dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null; + $dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null; + $dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null; $dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null; - $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; + $dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null; $dto->group = TimeEntryAggregationType::from($data->group); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup); $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); @@ -180,32 +179,12 @@ class ReportPropertiesDto implements Castable }; } - /** - * @param array $ids - * @return Collection - */ - public static function idArrayToCollection(array $ids): Collection - { - $collection = new Collection; - foreach ($ids as $id) { - if (! is_string($id)) { - throw new InvalidArgumentException('The given ID is not a string'); - } - if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) { - throw new InvalidArgumentException('The given ID is not a valid UUID'); - } - $collection->push($id); - } - - return $collection; - } - /** * @param array|null $memberIds */ public function setMemberIds(?array $memberIds): void { - $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null; + $this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null; } /** @@ -213,7 +192,7 @@ class ReportPropertiesDto implements Castable */ public function setClientIds(?array $clientIds): void { - $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null; + $this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null; } /** @@ -221,7 +200,7 @@ class ReportPropertiesDto implements Castable */ public function setProjectIds(?array $projectIds): void { - $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null; + $this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null; } /** @@ -229,7 +208,7 @@ class ReportPropertiesDto implements Castable */ public function setTagIds(?array $tagIds): void { - $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; + $this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null; } public function setTagMatchType(?TagMatchType $tagMatchType): void @@ -242,6 +221,6 @@ class ReportPropertiesDto implements Castable */ public function setTaskIds(?array $taskIds): void { - $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null; + $this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null; } } diff --git a/app/Service/GoalProgressService.php b/app/Service/GoalProgressService.php new file mode 100644 index 00000000..fa4d396a --- /dev/null +++ b/app/Service/GoalProgressService.php @@ -0,0 +1,92 @@ +copy()->setTimezone($goal->timezone); + + $start = match ($goal->period) { + GoalPeriod::Day => $dateInTimezone->copy()->startOfDay(), + GoalPeriod::Week => $dateInTimezone->copy()->startOfWeek($goal->week_start->carbonWeekDay()), + GoalPeriod::Month => $dateInTimezone->copy()->startOfMonth(), + }; + $end = match ($goal->period) { + GoalPeriod::Day => $start->copy()->addDay(), + GoalPeriod::Week => $start->copy()->addWeek(), + GoalPeriod::Month => $start->copy()->addMonth(), + }; + + return [$start->utc(), $end->utc()]; + } + + /** + * Seconds tracked in the given period that count towards the goal. + * Counts the time entries of the member of the goal, or of every member for goals without a member. + * Time entries are assigned to the period their start is in, like in the reporting. + * A running time entry counts with its elapsed time up to now. + * + * @param Carbon $periodStart Start of the period (inclusive) + * @param Carbon $periodEnd End of the period (exclusive) + */ + public function getProgress(Goal $goal, Carbon $periodStart, Carbon $periodEnd): int + { + $filters = $goal->filters; + + $query = TimeEntry::query() + ->where('organization_id', '=', $goal->organization_id); + if ($goal->member_id !== null) { + $query->where('member_id', '=', $goal->member_id); + } + $timeEntryFilter = new TimeEntryFilter($query); + $timeEntryFilter + ->addStart($periodStart) + ->addEnd($periodEnd) + ->addProjectIdsFilter($filters->projectIds?->toArray()) + ->addTaskIdsFilter($filters->taskIds?->toArray()) + ->addTagIdsFilter($filters->tagIds?->toArray(), $filters->tagMatchType) + ->addClientIdsFilter($filters->clientIds?->toArray()) + ->addBillable($filters->billable) + ->addType($filters->timeEntryType); + if ($goal->member_id === null) { + $timeEntryFilter->addMemberIdsFilter($filters->memberIds?->toArray()); + } + + /** @var object{tracked_seconds: int|float|string|null} $row */ + $row = $timeEntryFilter->get() + ->selectRaw('coalesce(round(sum(extract(epoch from (coalesce("end", ?::timestamp) - "start")))), 0) as tracked_seconds', [Carbon::now()]) + ->toBase() + ->first(); + + return max(0, (int) $row->tracked_seconds); + } + + public function getStatus(Goal $goal, int $trackedSeconds): GoalStatus + { + return match ($goal->comparison) { + GoalComparison::AtLeast => $trackedSeconds >= $goal->target_seconds + ? GoalStatus::Achieved + : GoalStatus::InProgress, + GoalComparison::LessThan => $trackedSeconds < $goal->target_seconds + ? GoalStatus::OnTrack + : GoalStatus::Exceeded, + }; + } +} diff --git a/app/Service/GoalsContract.php b/app/Service/GoalsContract.php new file mode 100644 index 00000000..9643818e --- /dev/null +++ b/app/Service/GoalsContract.php @@ -0,0 +1,64 @@ +getKey(); + } + + /** + * Whether $actor is allowed to see the goal (including its progress). + */ + public function canViewGoal(Member $actor, Goal $goal): bool + { + return $goal->isPersonalGoalOf($actor); + } + + /** + * Whether $actor is allowed to update the goal (including its filters and archiving it). + */ + public function canUpdateGoal(Member $actor, Goal $goal): bool + { + return $goal->isPersonalGoalOf($actor); + } + + /** + * Whether $actor is allowed to delete the goal. + */ + public function canDeleteGoal(Member $actor, Goal $goal): bool + { + return $goal->isPersonalGoalOf($actor); + } + + /** + * Restrict the query to the goals that $actor is allowed to see. + * + * @param Builder $query + * @return Builder + */ + public function scopeVisibleGoals(Builder $query, Member $actor): Builder + { + return $query->where('type', '=', GoalType::Personal->value) + ->where('member_id', '=', $actor->getKey()); + } +} diff --git a/app/Service/MemberService.php b/app/Service/MemberService.php index dc2df481..27e339e8 100644 --- a/app/Service/MemberService.php +++ b/app/Service/MemberService.php @@ -14,6 +14,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; +use App\Models\Goal; use App\Models\Member; use App\Models\Organization; use App\Models\Project; @@ -109,6 +110,12 @@ class MemberService } } + // A goal is deleted together with the member it is for, it is not part of the "still in use" check + Goal::query() + ->whereBelongsTo($organization, 'organization') + ->where('member_id', '=', $member->getKey()) + ->delete(); + $member->delete(); if ($isPlaceholder) { @@ -181,6 +188,30 @@ class MemberService ->whereBelongsToOrganization($organization) ->whereBelongsTo($fromMember, 'member') ->delete(); + + // Goals: the goal belongs to the person it is for, like time entries it moves along + Goal::query() + ->whereBelongsTo($organization, 'organization') + ->where('member_id', '=', $fromMember->getKey()) + ->update([ + 'member_id' => $toMember->getKey(), + ]); + + // Goal member filters: the time entries of $fromMember now belong to $toMember, so the filters follow them + $goalsWithMemberFilter = Goal::query() + ->whereBelongsTo($organization, 'organization') + ->whereJsonContains('filters->memberIds', $fromMember->getKey()) + ->get(); + foreach ($goalsWithMemberFilter as $goal) { + $filters = $goal->filters; + $memberIds = $filters->memberIds?->map( + fn (string $memberId): string => $memberId === $fromMember->getKey() ? $toMember->getKey() : $memberId + ); + // $toMember may already be in the filter + $filters->setMemberIds($memberIds?->unique()->values()->all()); + $goal->filters = $filters; + $goal->save(); + } } /** diff --git a/app/Service/PermissionStore.php b/app/Service/PermissionStore.php index 3d4af1b9..be9087f5 100644 --- a/app/Service/PermissionStore.php +++ b/app/Service/PermissionStore.php @@ -75,6 +75,14 @@ class PermissionStore 'reports:create', 'reports:update', 'reports:delete', + 'goals:view:own', + 'goals:create:own', + 'goals:update:own', + 'goals:delete:own', + 'goals:view:organization-type', + 'goals:create:organization-type', + 'goals:update:organization-type', + 'goals:delete:organization-type', 'invoices:view', 'invoices:create', 'invoices:update', @@ -146,6 +154,14 @@ class PermissionStore 'reports:create', 'reports:update', 'reports:delete', + 'goals:view:own', + 'goals:create:own', + 'goals:update:own', + 'goals:delete:own', + 'goals:view:organization-type', + 'goals:create:organization-type', + 'goals:update:organization-type', + 'goals:delete:organization-type', 'invoices:view', 'invoices:create', 'invoices:update', @@ -206,6 +222,14 @@ class PermissionStore 'reports:create', 'reports:update', 'reports:delete', + 'goals:view:own', + 'goals:create:own', + 'goals:update:own', + 'goals:delete:own', + 'goals:view:organization-type', + 'goals:create:organization-type', + 'goals:update:organization-type', + 'goals:delete:organization-type', 'invoices:view', 'invoices:create', 'invoices:update', @@ -233,6 +257,10 @@ class PermissionStore 'time-entries:update:own', 'time-entries:delete:own', 'organizations:view', + 'goals:view:own', + 'goals:create:own', + 'goals:update:own', + 'goals:delete:own', ], 'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.', ], diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 1f4b8541..dc954695 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -10,7 +10,9 @@ use App\Models\Member; use App\Models\TimeEntry; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class TimeEntryFilter { @@ -29,6 +31,26 @@ class TimeEntryFilter $this->builder = $builder; } + /** + * @param array $ids + * @return Collection + */ + public static function idArrayToCollection(array $ids): Collection + { + $collection = new Collection; + foreach ($ids as $id) { + if (! is_string($id)) { + throw new \InvalidArgumentException('The given ID is not a string'); + } + if ($id !== self::NONE_VALUE && ! Str::isUuid($id)) { + throw new \InvalidArgumentException('The given ID is not a valid UUID'); + } + $collection->push($id); + } + + return $collection; + } + public function addEndFilter(?string $dateTime): self { if ($dateTime === null) { diff --git a/database/factories/GoalFactory.php b/database/factories/GoalFactory.php new file mode 100644 index 00000000..cd17f2aa --- /dev/null +++ b/database/factories/GoalFactory.php @@ -0,0 +1,161 @@ + + */ +class GoalFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $filters = new GoalFiltersDto; + $filters->timeEntryType = TimeEntryType::Work; + + return [ + 'name' => $this->faker->sentence(3), + 'type' => GoalType::Personal, + 'comparison' => $this->faker->randomElement(GoalComparison::cases()), + 'target_seconds' => $this->faker->numberBetween(1, 40) * 3600, + 'period' => $this->faker->randomElement(GoalPeriod::cases()), + 'filters' => $filters, + 'timezone' => 'UTC', + 'week_start' => Weekday::Monday, + 'archived_at' => null, + 'organization_id' => Organization::factory(), + // The member has to belong to the organization of the goal + 'member_id' => fn (array $attributes) => Member::factory()->state([ + 'organization_id' => $attributes['organization_id'], + ]), + ]; + } + + /** + * Goal in the given organization, for a new member of that organization unless forMember() is used. + */ + public function forOrganization(Organization $organization): self + { + return $this->state(fn (array $attributes): array => [ + 'organization_id' => $organization->getKey(), + 'member_id' => Member::factory()->forOrganization($organization), + ]); + } + + /** + * Personal goal of the given member. + */ + public function forMember(Member $member): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => GoalType::Personal, + 'member_id' => $member->getKey(), + 'organization_id' => $member->organization_id, + ]); + } + + /** + * Organization goal that counts the time of the given member. + */ + public function organizationGoalForMember(Member $member): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => GoalType::Organization, + 'member_id' => $member->getKey(), + 'organization_id' => $member->organization_id, + ]); + } + + /** + * Organization goal that counts the time of every member. + */ + public function forEveryMember(): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => GoalType::Organization, + 'member_id' => null, + ]); + } + + public function type(GoalType $type): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => $type, + ]); + } + + public function archived(): self + { + return $this->state(fn (array $attributes): array => [ + 'archived_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + ]); + } + + public function timezone(string $timezone): self + { + return $this->state(fn (array $attributes): array => [ + 'timezone' => $timezone, + ]); + } + + public function weekStart(Weekday $weekStart): self + { + return $this->state(fn (array $attributes): array => [ + 'week_start' => $weekStart, + ]); + } + + public function atLeast(int $targetSeconds): self + { + return $this->state(fn (array $attributes): array => [ + 'comparison' => GoalComparison::AtLeast, + 'target_seconds' => $targetSeconds, + ]); + } + + public function lessThan(int $targetSeconds): self + { + return $this->state(fn (array $attributes): array => [ + 'comparison' => GoalComparison::LessThan, + 'target_seconds' => $targetSeconds, + ]); + } + + public function period(GoalPeriod $period): self + { + return $this->state(fn (array $attributes): array => [ + 'period' => $period, + ]); + } + + public function filters(GoalFiltersDto $filters): self + { + return $this->state(fn (array $attributes): array => [ + 'filters' => $filters, + ]); + } + + public function randomCreatedAt(): self + { + return $this->state(fn (array $attributes): array => [ + 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + ]); + } +} diff --git a/database/migrations/2026_09_02_000001_create_goals_table.php b/database/migrations/2026_09_02_000001_create_goals_table.php new file mode 100644 index 00000000..a6215b5d --- /dev/null +++ b/database/migrations/2026_09_02_000001_create_goals_table.php @@ -0,0 +1,56 @@ +uuid('id')->primary(); + $table->string('name'); + // personal: a goal a member set for themselves, organization: a goal the organization set (team goals extension) + $table->string('type'); + $table->string('comparison'); + $table->unsignedBigInteger('target_seconds'); + $table->string('period'); + $table->jsonb('filters'); + // Timezone and week start define the periods of the goal, they are pinned on the goal like on a report + $table->string('timezone'); + $table->string('week_start'); + $table->dateTime('archived_at')->nullable(); + $table->uuid('organization_id'); + $table->foreign('organization_id') + ->references('id') + ->on('organizations') + ->restrictOnDelete() + ->cascadeOnUpdate(); + // Member whose time entries count towards the goal, null means every member of the organization + // (organization goals only). Personal goals always have a member. + $table->uuid('member_id')->nullable(); + $table->foreign('member_id') + ->references('id') + ->on('members') + ->restrictOnDelete() + ->cascadeOnUpdate(); + $table->timestamps(); + $table->index(['organization_id', 'member_id']); + $table->index(['organization_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('goals'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index dfbe153b..acc93f83 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,11 +4,14 @@ declare(strict_types=1); namespace Database\Seeders; +use App\Enums\GoalPeriod; use App\Enums\Role; +use App\Enums\TimeEntryType; use App\Events\DatabaseSeederAfterSeed; use App\Events\DatabaseSeederBeforeDelete; use App\Models\Audit; use App\Models\Client; +use App\Models\Goal; use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; @@ -19,6 +22,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; +use App\Service\Dto\GoalFiltersDto; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; use Laravel\Passport\AuthCode; @@ -114,13 +118,28 @@ class DatabaseSeeder extends Seeder Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Code Review', ]); - Tag::factory()->forOrganization($organizationAcme)->create([ + $tagMeeting = Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Meeting', ]); Tag::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Research', ]); + $workFilters = new GoalFiltersDto; + $workFilters->timeEntryType = TimeEntryType::Work; + Goal::factory()->forMember($userAcmeOwnerMember)->atLeast(6 * 3600)->period(GoalPeriod::Day)->filters($workFilters)->create([ + 'name' => 'Track at least 6 hours per day', + ]); + $meetingFilters = new GoalFiltersDto; + $meetingFilters->setTagIds([$tagMeeting->getKey()]); + $meetingFilters->timeEntryType = TimeEntryType::Work; + Goal::factory()->forMember($userAcmeOwnerMember)->lessThan(5 * 3600)->period(GoalPeriod::Week)->filters($meetingFilters)->create([ + 'name' => 'Less than 5 hours of meetings per week', + ]); + Goal::factory()->forMember($userAcmeEmployeeMember)->atLeast(30 * 3600)->period(GoalPeriod::Week)->filters($workFilters)->create([ + 'name' => 'Track 30 hours per week', + ]); + TimeEntry::factory() ->count(10) ->forMember($userAcmeAdminMember) @@ -219,6 +238,7 @@ class DatabaseSeeder extends Seeder // Application tables DB::table((new Audit)->getTable())->delete(); DB::table((new Report)->getTable())->delete(); + DB::table((new Goal)->getTable())->delete(); DB::table((new TimeEntry)->getTable())->delete(); DB::table((new Task)->getTable())->delete(); DB::table((new Tag)->getTable())->delete(); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 85f5ec3f..09db6bf4 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -292,6 +292,16 @@ test.describe('Command Palette', () => { await expect(page.locator('[role="dialog"]').getByText('Create Tags')).toBeVisible(); }); + test('opens create goal modal', async ({ page }) => { + await goToDashboard(page); + await openCommandPalette(page); + await searchInCommandPalette(page, 'Create Goal'); + await selectCommand(page, 'Create Goal'); + await expect( + page.locator('[role="dialog"]').getByRole('heading', { name: 'Create Goal' }) + ).toBeVisible(); + }); + test('opens invite member modal', async ({ page }) => { await goToDashboard(page); await openCommandPalette(page); @@ -466,6 +476,9 @@ test.describe('Employee Command Palette Restrictions', () => { employee.page.getByRole('option', { name: 'Invite Member' }) ).not.toBeVisible(); + // Employees can set personal goals for themselves + await expect(employee.page.getByRole('option', { name: 'Create Goal' })).toBeVisible(); + // Should still see Create Time Entry (employees can create time entries) await expect( employee.page.getByRole('option', { name: 'Create Time Entry' }) diff --git a/e2e/goals.spec.ts b/e2e/goals.spec.ts new file mode 100644 index 00000000..0a852e43 --- /dev/null +++ b/e2e/goals.spec.ts @@ -0,0 +1,688 @@ +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { + createClientViaApi, + createGoalViaApi, + createProjectViaApi, + createProjectMemberViaApi, + setupTestContext, + createRunningTimeEntryWithStartViaApi, + createTagViaApi, + createTaskViaApi, + createTimeEntryViaApi, + deleteClientViaApi, + deleteProjectViaApi, + deleteTagViaApi, + deleteTaskViaApi, + updateGoalViaApi, + updateOrganizationSettingViaApi, + updateUserProfileViaApi, +} from './utils/api'; +import { + selectTimezone, + selectWeekStart, + timezoneField, + weekStartField, +} from './utils/userSettingsFields'; + +async function goToGoalsOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/goals'); + await expect(page.getByTestId('goals_view')).toBeVisible(); +} + +function goalRow(page: Page, goalId: string) { + return page.getByTestId('goal_row_' + goalId); +} + +function goalResponse(page: Page, method: 'POST' | 'PUT' | 'DELETE', status: number) { + return page.waitForResponse( + (response) => + response.url().includes('/goals') && + response.request().method() === method && + response.status() === status + ); +} + +test('test that the goals page shows an empty state and the sidebar link works', async ({ + page, +}) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await page.getByRole('link', { name: 'Goals' }).first().click(); + await expect(page).toHaveURL(/\/goals$/); + await expect(page.getByTestId('goal_table')).toContainText('No goals found'); + await expect(page.getByRole('button', { name: 'Create your first goal' })).toBeVisible(); +}); + +test('test that creating a goal via the modal works and shows zero progress', async ({ page }) => { + const goalName = 'Deep work ' + Math.floor(1 + Math.random() * 10000); + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill(goalName); + await page.getByTestId('duration_seconds_input').fill('10h'); + await page.getByTestId('duration_seconds_input').press('Tab'); + await page.getByRole('combobox', { name: 'Period' }).click(); + await page.getByRole('option', { name: 'per day' }).click(); + + const [response] = await Promise.all([ + goalResponse(page, 'POST', 201), + page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(), + ]); + const body = await response.json(); + expect(body.data.name).toBe(goalName); + expect(body.data.target_seconds).toBe(36000); + expect(body.data.period).toBe('day'); + expect(body.data.comparison).toBe('at_least'); + expect(body.data.filters.time_entry_type).toBe('work'); + expect(body.data.type).toBe('personal'); + expect(body.data.is_archived).toBe(false); + + const row = goalRow(page, body.data.id); + await expect(row).toBeVisible(); + await expect(row.getByTestId('goal_name')).toHaveText(goalName); + await expect(row.getByTestId('goal_target_description')).toHaveText( + 'At least 10h 00min per day' + ); + await expect(row.getByTestId('goal_tracked_time')).toHaveText('0h 00min'); + await expect(row).toContainText('of 10h 00min'); + await expect(row).toContainText('In progress'); +}); + +test('test that the create goal modal validates name and target', async ({ page }) => { + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(); + await expect(page.getByRole('alert').filter({ hasText: 'name' })).toBeVisible(); + await expect(page.getByRole('alert').filter({ hasText: 'target time' })).toBeVisible(); +}); + +test('test that goal progress reflects tracked time entries that match the filters', async ({ + page, + ctx, +}) => { + const project = await createProjectViaApi(ctx, { name: 'Goal Project', is_public: true }); + const otherProject = await createProjectViaApi(ctx, { name: 'Other Project', is_public: true }); + await createTimeEntryViaApi(ctx, { duration: '2h', projectId: project.id }); + await createTimeEntryViaApi(ctx, { duration: '1h 30min', projectId: otherProject.id }); + await createTimeEntryViaApi(ctx, { duration: '45min' }); + + const projectGoal = await createGoalViaApi(ctx, { + name: 'Project goal', + target_seconds: 4 * 3600, + period: 'day', + filters: { project_ids: [project.id], time_entry_type: 'work' }, + }); + const allGoal = await createGoalViaApi(ctx, { + name: 'All time goal', + target_seconds: 4 * 3600, + period: 'day', + }); + const lessThanGoal = await createGoalViaApi(ctx, { + name: 'Less than goal', + comparison: 'less_than', + target_seconds: 3600, + period: 'day', + filters: { project_ids: [project.id], time_entry_type: 'work' }, + }); + + expect(projectGoal.progress.tracked_seconds).toBe(2 * 3600); + expect(allGoal.progress.tracked_seconds).toBe(2 * 3600 + 90 * 60 + 45 * 60); + expect(lessThanGoal.progress.status).toBe('exceeded'); + + await goToGoalsOverview(page); + await expect(goalRow(page, projectGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '2h 00min' + ); + await expect(goalRow(page, projectGoal.id)).toContainText('Goal Project'); + await expect(goalRow(page, projectGoal.id)).toContainText('In progress'); + await expect(goalRow(page, allGoal.id).getByTestId('goal_tracked_time')).toHaveText('4h 15min'); + await expect(goalRow(page, allGoal.id)).toContainText('Achieved'); + await expect(goalRow(page, allGoal.id)).toContainText('All time entries'); + await expect(goalRow(page, lessThanGoal.id)).toContainText('Exceeded'); + await expect(goalRow(page, lessThanGoal.id)).toContainText('Less than 1h 00min per day'); +}); + +async function openCreateGoalModal(page: Page, name: string) { + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill(name); + await page.getByTestId('duration_seconds_input').fill('10h'); + await page.getByTestId('duration_seconds_input').press('Tab'); +} + +async function submitCreateGoalModal(page: Page) { + const [response] = await Promise.all([ + goalResponse(page, 'POST', 201), + page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(), + ]); + return (await response.json()).data; +} + +async function selectGoalFilterOption( + page: Page, + filter: string, + searchPlaceholder: string, + option: string +) { + await page.getByTestId('goal_filter_' + filter).click(); + await page.getByRole('option', { name: option }).click(); + await page.getByPlaceholder(searchPlaceholder).press('Escape'); +} + +test('test that the task filter of a goal only counts entries of the selected tasks', async ({ + page, + ctx, +}) => { + const project = await createProjectViaApi(ctx, { + name: 'Task filter project', + is_public: true, + }); + const task = await createTaskViaApi(ctx, { name: 'Counted task', project_id: project.id }); + const otherTask = await createTaskViaApi(ctx, { name: 'Other task', project_id: project.id }); + await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id, taskId: task.id }); + await createTimeEntryViaApi(ctx, { + duration: '2h', + projectId: project.id, + taskId: otherTask.id, + }); + await createTimeEntryViaApi(ctx, { duration: '30min', projectId: project.id }); + + await openCreateGoalModal(page, 'Task goal'); + await selectGoalFilterOption(page, 'task_ids', 'Search for a Task...', 'Counted task'); + await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*1/); + const goal = await submitCreateGoalModal(page); + + expect(goal.filters.task_ids).toEqual([task.id]); + expect(goal.progress.tracked_seconds).toBe(3600); + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min'); +}); + +test('test that the client filter of a goal only counts entries of projects of the selected clients', async ({ + page, + ctx, +}) => { + const client = await createClientViaApi(ctx, { name: 'Counted client' }); + const otherClient = await createClientViaApi(ctx, { name: 'Other client' }); + const project = await createProjectViaApi(ctx, { + name: 'Client project', + client_id: client.id, + is_public: true, + }); + const otherProject = await createProjectViaApi(ctx, { + name: 'Other client project', + client_id: otherClient.id, + is_public: true, + }); + await createTimeEntryViaApi(ctx, { duration: '1h', projectId: project.id }); + await createTimeEntryViaApi(ctx, { duration: '2h', projectId: otherProject.id }); + await createTimeEntryViaApi(ctx, { duration: '30min' }); + + await openCreateGoalModal(page, 'Client goal'); + await selectGoalFilterOption(page, 'client_ids', 'Search for a Client...', 'Counted client'); + await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/); + const goal = await submitCreateGoalModal(page); + + expect(goal.filters.client_ids).toEqual([client.id]); + expect(goal.progress.tracked_seconds).toBe(3600); + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('1h 00min'); +}); + +test('test that the tag filter of a goal respects the tag match type', async ({ page, ctx }) => { + const tag = await createTagViaApi(ctx, { name: 'Counted tag' }); + const otherTag = await createTagViaApi(ctx, { name: 'Other tag' }); + await createTimeEntryViaApi(ctx, { duration: '1h', tags: [tag.id] }); + await createTimeEntryViaApi(ctx, { duration: '2h', tags: [otherTag.id] }); + await createTimeEntryViaApi(ctx, { duration: '30min' }); + + await openCreateGoalModal(page, 'Contains tag goal'); + await selectGoalFilterOption(page, 'tag_ids', 'Search for a Tag...', 'Counted tag'); + await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*1/); + const containsGoal = await submitCreateGoalModal(page); + expect(containsGoal.filters.tag_ids).toEqual([tag.id]); + expect(containsGoal.filters.tag_match_type).toBe('contains'); + expect(containsGoal.progress.tracked_seconds).toBe(3600); + + await openCreateGoalModal(page, 'Not contains tag goal'); + await page.getByTestId('goal_filter_tag_ids').click(); + await page.getByRole('radio', { name: 'Does Not Contain' }).click(); + await page.getByRole('option', { name: 'Counted tag' }).click(); + await page.getByPlaceholder('Search for a Tag...').press('Escape'); + const notContainsGoal = await submitCreateGoalModal(page); + expect(notContainsGoal.filters.tag_ids).toEqual([tag.id]); + expect(notContainsGoal.filters.tag_match_type).toBe('not_contains'); + // Entries with other tags and entries without tags count + expect(notContainsGoal.progress.tracked_seconds).toBe(2 * 3600 + 30 * 60); + + await expect(goalRow(page, containsGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '1h 00min' + ); + await expect(goalRow(page, notContainsGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '2h 30min' + ); +}); + +test('test that the billable filter of a goal only counts entries with the selected billable status', async ({ + page, + ctx, +}) => { + await createTimeEntryViaApi(ctx, { duration: '1h', billable: true }); + await createTimeEntryViaApi(ctx, { duration: '2h', billable: false }); + + await openCreateGoalModal(page, 'Billable goal'); + await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + const billableGoal = await submitCreateGoalModal(page); + expect(billableGoal.filters.billable).toBe(true); + expect(billableGoal.progress.tracked_seconds).toBe(3600); + + await openCreateGoalModal(page, 'Non billable goal'); + await page.getByRole('combobox').filter({ hasText: 'Billable' }).click(); + await page.getByRole('option', { name: 'Non Billable' }).click(); + const nonBillableGoal = await submitCreateGoalModal(page); + expect(nonBillableGoal.filters.billable).toBe(false); + expect(nonBillableGoal.progress.tracked_seconds).toBe(2 * 3600); + + await expect(goalRow(page, billableGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '1h 00min' + ); + await expect(goalRow(page, nonBillableGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '2h 00min' + ); +}); + +test('test that the type filter of a goal only counts entries of the selected type', async ({ + page, + ctx, +}) => { + await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true }); + await createTimeEntryViaApi(ctx, { duration: '1h' }); + await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' }); + + await openCreateGoalModal(page, 'Break goal'); + await page.getByRole('combobox').filter({ hasText: 'Work time' }).click(); + await page.getByRole('option', { name: 'Breaks' }).click(); + const breakGoal = await submitCreateGoalModal(page); + expect(breakGoal.filters.time_entry_type).toBe('break'); + expect(breakGoal.progress.tracked_seconds).toBe(30 * 60); + + await openCreateGoalModal(page, 'Work and break goal'); + await page.getByRole('combobox').filter({ hasText: 'Work time' }).click(); + await page.getByRole('option', { name: 'Both' }).click(); + const bothGoal = await submitCreateGoalModal(page); + expect(bothGoal.filters.time_entry_type).toBeNull(); + expect(bothGoal.progress.tracked_seconds).toBe(3600 + 30 * 60); + + await expect(goalRow(page, breakGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '0h 30min' + ); + await expect(goalRow(page, bothGoal.id).getByTestId('goal_tracked_time')).toHaveText( + '1h 30min' + ); +}); + +test('test that a running time entry counts towards the goal progress', async ({ page, ctx }) => { + const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start); + // Close to midnight UTC the entry would start on the previous day, so the day of the goal + // is moved to a timezone (without daylight saving time) where it is late morning instead + const utcHour = new Date().getUTCHours(); + const goal = await createGoalViaApi(ctx, { + name: 'Running goal', + target_seconds: 3600, + period: 'day', + timezone: utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo', + }); + expect(goal.progress.tracked_seconds).toBeGreaterThanOrEqual(30 * 60); + + await goToGoalsOverview(page); + // The entry keeps running while the page loads + await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText( + /^0h 3[0-2]min$/ + ); +}); + +test('test that editing a goal via the modal works', async ({ page, ctx }) => { + const goal = await createGoalViaApi(ctx, { + name: 'Old goal name', + target_seconds: 3600, + period: 'week', + }); + await goToGoalsOverview(page); + await page.locator("[aria-label='Actions for Goal Old goal name']").click(); + await page.locator("[aria-label='Edit Goal Old goal name']").click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('New goal name'); + await page.getByRole('combobox', { name: 'Target type' }).click(); + await page.getByRole('option', { name: 'Less than' }).click(); + + const [response] = await Promise.all([ + goalResponse(page, 'PUT', 200), + page.getByRole('button', { name: 'Update Goal' }).click(), + ]); + const body = await response.json(); + expect(body.data.name).toBe('New goal name'); + expect(body.data.comparison).toBe('less_than'); + + const row = goalRow(page, goal.id); + await expect(row.getByTestId('goal_name')).toHaveText('New goal name'); + await expect(row).toContainText('Less than 1h 00min per week'); + await expect(row).toContainText('On track'); +}); + +test('test that a goal whose filters point to deleted entities can still be edited', async ({ + page, + ctx, +}) => { + const keptProject = await createProjectViaApi(ctx, { name: 'Kept project' }); + const deletedProject = await createProjectViaApi(ctx, { name: 'Deleted project' }); + const keptTask = await createTaskViaApi(ctx, { name: 'Kept task', project_id: keptProject.id }); + const deletedTask = await createTaskViaApi(ctx, { + name: 'Deleted task', + project_id: keptProject.id, + }); + const keptTag = await createTagViaApi(ctx, { name: 'Kept tag' }); + const deletedTag = await createTagViaApi(ctx, { name: 'Deleted tag' }); + const deletedClient = await createClientViaApi(ctx, { name: 'Deleted client' }); + const filters = { + project_ids: [keptProject.id, deletedProject.id], + task_ids: [keptTask.id, deletedTask.id], + // "none" is no entity, it has to survive the cleanup + tag_ids: ['none', keptTag.id, deletedTag.id], + // Every client of this filter gets deleted + client_ids: [deletedClient.id], + time_entry_type: 'work' as const, + }; + const goal = await createGoalViaApi(ctx, { + name: 'Stale filters', + target_seconds: 3600, + filters, + }); + await deleteTaskViaApi(ctx, deletedTask.id); + await deleteProjectViaApi(ctx, deletedProject.id); + await deleteTagViaApi(ctx, deletedTag.id); + await deleteClientViaApi(ctx, deletedClient.id); + + // Resending deleted IDs fails; unrelated edits can omit filters to preserve the scope. + const rejected = await updateGoalViaApi(ctx, goal.id, { filters }); + expect(rejected.status()).toBe(422); + const errors = (await rejected.json()).errors; + expect(Object.keys(errors).sort()).toEqual([ + 'filters.client_ids.0', + 'filters.project_ids.1', + 'filters.tag_ids.2', + 'filters.task_ids.1', + ]); + + await goToGoalsOverview(page); + await page.locator("[aria-label='Actions for Goal Stale filters']").click(); + await page.locator("[aria-label='Edit Goal Stale filters']").click(); + // Opening the form must preserve the scope, even after lists have loaded. + await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*2/); + await expect(page.getByTestId('goal_filter_task_ids')).toHaveText(/Tasks\s*2/); + await expect(page.getByTestId('goal_filter_tag_ids')).toHaveText(/Tags\s*3/); + await expect(page.getByTestId('goal_filter_client_ids')).toHaveText(/Clients\s*1/); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('Preserved filters'); + await page.getByRole('button', { name: 'Update Goal' }).click(); + await expect(page.getByTestId('goal_unavailable_filters')).toContainText( + 'This will count all clients' + ); + const [preservedResponse] = await Promise.all([ + goalResponse(page, 'PUT', 200), + page.getByRole('button', { name: 'Keep existing filters' }).click(), + ]); + expect(preservedResponse.request().postDataJSON()).not.toHaveProperty('filters'); + expect((await preservedResponse.json()).data.filters).toMatchObject(filters); + + await page.locator("[aria-label='Actions for Goal Preserved filters']").click(); + await page.locator("[aria-label='Edit Goal Preserved filters']").click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('Cleaned filters'); + await page.getByRole('button', { name: 'Update Goal' }).click(); + await expect(page.getByTestId('goal_unavailable_filters')).toBeVisible(); + const [response] = await Promise.all([ + goalResponse(page, 'PUT', 200), + page.getByRole('button', { name: 'Remove unavailable items' }).click(), + ]); + const body = await response.json(); + expect(body.data.name).toBe('Cleaned filters'); + expect(body.data.filters.project_ids).toEqual([keptProject.id]); + expect(body.data.filters.task_ids).toEqual([keptTask.id]); + expect(body.data.filters.tag_ids).toEqual(['none', keptTag.id]); + // A filter whose entities are all gone is dropped, the goal no longer filters by client + expect(body.data.filters.client_ids).toBeNull(); + await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Cleaned filters'); +}); + +test('losing access to a private project warns without silently changing a personal goal', async ({ + ctx, + employee, +}) => { + test.setTimeout(120 * 1000); + const project = await createProjectViaApi(ctx, { + name: 'Private goal project', + is_public: false, + }); + const membership = await createProjectMemberViaApi(ctx, project.id, { + member_id: employee.memberId, + }); + const employeeCtx = { + ...(await setupTestContext(employee.page)), + orgId: ctx.orgId, + memberId: employee.memberId, + }; + const goal = await createGoalViaApi(employeeCtx, { + name: 'Private project goal', + target_seconds: 3600, + filters: { project_ids: [project.id], time_entry_type: 'work' }, + }); + const removed = await ctx.request.delete( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/project-members/${membership.id}` + ); + expect(removed.status()).toBe(204); + // The project still exists, but the employee's selectable list no longer contains it. + const projects = await employeeCtx.request.get( + `${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/projects?archived=all` + ); + expect(projects.status()).toBe(200); + expect((await projects.json()).data.map((item: { id: string }) => item.id)).not.toContain( + project.id + ); + + const page = employee.page; + await goToGoalsOverview(page); + await page.locator("[aria-label='Actions for Goal Private project goal']").click(); + await page.locator("[aria-label='Edit Goal Private project goal']").click(); + await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('Still scoped'); + await page.getByRole('button', { name: 'Update Goal' }).click(); + await expect(page.getByTestId('goal_unavailable_filters')).toContainText( + 'Unavailable projects: 1' + ); + await expect(page.getByTestId('goal_unavailable_filters')).toContainText( + 'This will count all projects' + ); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/); + await page.getByRole('button', { name: 'Update Goal' }).click(); + const [response] = await Promise.all([ + goalResponse(page, 'PUT', 200), + page.getByRole('button', { name: 'Keep existing filters' }).click(), + ]); + expect(response.request().postDataJSON()).not.toHaveProperty('filters'); + expect((await response.json()).data.filters.project_ids).toEqual([project.id]); + await expect(goalRow(page, goal.id).getByTestId('goal_name')).toHaveText('Still scoped'); +}); + +test('test that archiving a goal moves it to the archived tab and back', async ({ page, ctx }) => { + const goal = await createGoalViaApi(ctx, { + name: 'Goal to archive', + target_seconds: 3600, + }); + await goToGoalsOverview(page); + await expect(goalRow(page, goal.id)).toBeVisible(); + + await page.locator("[aria-label='Actions for Goal Goal to archive']").click(); + await Promise.all([ + goalResponse(page, 'PUT', 200), + page.locator("[aria-label='Archive Goal Goal to archive']").click(), + ]); + await expect(goalRow(page, goal.id)).not.toBeVisible(); + await expect(page.getByTestId('goal_table')).toContainText('No goals found'); + + await page.getByRole('tab', { name: 'Archived' }).click(); + const archivedRow = goalRow(page, goal.id); + await expect(archivedRow).toBeVisible(); + await expect(archivedRow.getByTestId('goal_archived_badge')).toBeVisible(); + + await page.locator("[aria-label='Actions for Goal Goal to archive']").click(); + await Promise.all([ + goalResponse(page, 'PUT', 200), + page.locator("[aria-label='Unarchive Goal Goal to archive']").click(), + ]); + await expect(goalRow(page, goal.id)).not.toBeVisible(); + await page.getByRole('tab', { name: 'Active' }).click(); + await expect(goalRow(page, goal.id)).toBeVisible(); +}); + +test('test that the period settings of a goal can be changed in the accordion', async ({ + page, + ctx, +}) => { + const goal = await createGoalViaApi(ctx, { + name: 'Goal with timezone', + target_seconds: 3600, + period: 'week', + timezone: 'Asia/Tokyo', + week_start: 'friday', + }); + await goToGoalsOverview(page); + await page.locator("[aria-label='Actions for Goal Goal with timezone']").click(); + await page.locator("[aria-label='Edit Goal Goal with timezone']").click(); + await page.getByTestId('goal_period_settings').click(); + const dialog = page.getByRole('dialog'); + await expect(timezoneField(dialog)).toHaveText('Asia/Tokyo'); + await expect(weekStartField(dialog)).toHaveText('Friday'); + + await selectTimezone(page, dialog, 'America/New_York', 'new york'); + await selectWeekStart(page, dialog, 'Sunday'); + + const [response] = await Promise.all([ + goalResponse(page, 'PUT', 200), + page.getByRole('button', { name: 'Update Goal' }).click(), + ]); + const body = await response.json(); + expect(body.data.id).toBe(goal.id); + expect(body.data.timezone).toBe('America/New_York'); + expect(body.data.week_start).toBe('sunday'); +}); + +test('test that the period settings of a new goal default to the user settings', async ({ + page, + ctx, +}) => { + // Same UTC offset as the browser, so the timezone mismatch modal stays closed + await updateUserProfileViaApi(ctx, { timezone: 'Atlantic/Reykjavik', week_start: 'wednesday' }); + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal with defaults'); + await page.getByTestId('duration_seconds_input').fill('10h'); + await page.getByTestId('duration_seconds_input').press('Tab'); + await page.getByTestId('goal_period_settings').click(); + await expect(timezoneField(page.getByRole('dialog'))).toHaveText('Atlantic/Reykjavik'); + await expect(weekStartField(page.getByRole('dialog'))).toHaveText('Wednesday'); + + const [response] = await Promise.all([ + goalResponse(page, 'POST', 201), + page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(), + ]); + const body = await response.json(); + expect(body.data.timezone).toBe('Atlantic/Reykjavik'); + expect(body.data.week_start).toBe('wednesday'); +}); + +test('test that the period settings of a new goal can be set in the create modal', async ({ + page, +}) => { + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByPlaceholder('e.g. Deep work on Project X').fill('Goal in New York'); + await page.getByTestId('duration_seconds_input').fill('10h'); + await page.getByTestId('duration_seconds_input').press('Tab'); + await page.getByTestId('goal_period_settings').click(); + const dialog = page.getByRole('dialog'); + await selectTimezone(page, dialog, 'America/New_York', 'new york'); + await selectWeekStart(page, dialog, 'Saturday'); + + const [response] = await Promise.all([ + goalResponse(page, 'POST', 201), + page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(), + ]); + const body = await response.json(); + expect(body.data.name).toBe('Goal in New York'); + expect(body.data.timezone).toBe('America/New_York'); + expect(body.data.week_start).toBe('saturday'); +}); + +test('test that the search of a goal filter can be used with the keyboard', async ({ + page, + ctx, +}) => { + await createProjectViaApi(ctx, { name: 'Alpha keyboard project', is_public: true }); + await createProjectViaApi(ctx, { name: 'Beta keyboard project', is_public: true }); + await goToGoalsOverview(page); + await page.getByRole('button', { name: 'Create Goal' }).click(); + await page.getByTestId('goal_filter_project_ids').click(); + + const search = page.getByPlaceholder('Search for a Project...'); + await search.click(); + await search.pressSequentially('alpha'); + await expect(search).toHaveValue('alpha'); + await expect(page.getByRole('option', { name: 'Beta keyboard project' })).toBeHidden(); + + // The arrow keys highlight an option while the focus stays in the search + const alpha = page.getByRole('option', { name: 'Alpha keyboard project' }); + await search.press('ArrowDown'); + await expect(alpha).toHaveAttribute('data-highlighted', ''); + await search.press('Enter'); + await expect(search).toBeFocused(); + await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/); + + // Escape closes the dropdown but keeps the modal and the selection + await search.press('Escape'); + await expect(search).toBeHidden(); + await expect(page.getByPlaceholder('e.g. Deep work on Project X')).toBeVisible(); + await expect(page.getByTestId('goal_filter_project_ids')).toHaveText(/Projects\s*1/); +}); + +test('test that deleting a goal via the more options dropdown works', async ({ page, ctx }) => { + const goal = await createGoalViaApi(ctx, { + name: 'Goal to delete', + target_seconds: 3600, + }); + await goToGoalsOverview(page); + await expect(goalRow(page, goal.id)).toBeVisible(); + await page.locator("[aria-label='Actions for Goal Goal to delete']").click(); + await Promise.all([ + goalResponse(page, 'DELETE', 204), + page.locator("[aria-label='Delete Goal Goal to delete']").click(), + ]); + await expect(goalRow(page, goal.id)).not.toBeVisible(); + await expect(page.getByTestId('goal_table')).toContainText('No goals found'); +}); + +test.describe('employee', () => { + test('employee can create and see their own goals', async ({ employee }) => { + await goToGoalsOverview(employee.page); + await employee.page.getByRole('button', { name: 'Create Goal' }).click(); + await employee.page.getByPlaceholder('e.g. Deep work on Project X').fill('Employee goal'); + await employee.page.getByTestId('duration_seconds_input').fill('2h'); + await employee.page.getByTestId('duration_seconds_input').press('Tab'); + const [response] = await Promise.all([ + goalResponse(employee.page, 'POST', 201), + employee.page.getByRole('button', { name: 'Create Goal', exact: true }).last().click(), + ]); + const body = await response.json(); + expect(body.data.type).toBe('personal'); + expect(body.data.member_id).toBe(employee.memberId); + await expect(goalRow(employee.page, body.data.id)).toContainText('Employee goal'); + }); +}); diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index b7815bb0..df695b89 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -7,6 +7,12 @@ import { } from './utils/mailpit'; import { getCurrentUserViaApi } from './utils/api'; import { registerUser } from './utils/members'; +import { + selectTimezone, + selectWeekStart, + timezoneField, + weekStartField, +} from './utils/userSettingsFields'; import type { Page } from '@playwright/test'; import path from 'path'; @@ -40,20 +46,51 @@ test('user name can be updated', async ({ page }) => { await expect(page.getByLabel('Name', { exact: true })).toHaveValue('NEW NAME'); }); +// The tests below pick UTC+0 timezones: the browser runs in UTC, and any other offset +// opens the timezone mismatch modal after the reload. test('timezone change persists across reload', async ({ page }) => { await goToProfilePage(page); - await page.getByLabel('Timezone').selectOption('America/New_York'); + await selectTimezone(page, page, 'Africa/Sao_Tome', 'sao tome'); await saveProfileForm(page); await page.reload(); - await expect(page.getByLabel('Timezone')).toHaveValue('America/New_York'); + await expect(timezoneField(page)).toHaveText('Africa/Sao_Tome'); +}); + +test('timezone can be selected with the keyboard', async ({ page }) => { + await goToProfilePage(page); + await expect(timezoneField(page)).toBeEnabled(); + await timezoneField(page).focus(); + await page.keyboard.press('Enter'); + const search = page.getByRole('combobox', { name: 'Search timezones' }); + await expect(search).toBeFocused(); + await search.fill('Africa/Abid'); + await expect(page.getByRole('option')).toHaveText(['Africa/Abidjan']); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(timezoneField(page)).toHaveText('Africa/Abidjan'); + await saveProfileForm(page); + await page.reload(); + await expect(timezoneField(page)).toHaveText('Africa/Abidjan'); +}); + +test('timezone search shows an empty state when nothing matches', async ({ page }) => { + await goToProfilePage(page); + await timezoneField(page).click(); + const search = page.getByRole('combobox', { name: 'Search timezones' }); + // Opening starts with an empty search, not one prefilled with the current timezone + await expect(search).toHaveValue(''); + await expect(page.getByRole('option').nth(1)).toBeVisible(); + await search.fill('Atlantis'); + await expect(page.getByText('No timezone found.')).toBeVisible(); + await expect(page.getByRole('option')).toHaveCount(0); }); test('week-start change persists across reload', async ({ page }) => { await goToProfilePage(page); - await page.getByLabel('Start of the week').selectOption('sunday'); + await selectWeekStart(page, page, 'Sunday'); await saveProfileForm(page); await page.reload(); - await expect(page.getByLabel('Start of the week')).toHaveValue('sunday'); + await expect(weekStartField(page)).toHaveText('Sunday'); }); test('still-running email notification setting persists across reload', async ({ page }) => { @@ -565,7 +602,10 @@ test('test that theme can be changed to dark and light', async ({ page }) => { await goToProfilePage(page); // The theme select is a Reka UI combobox (button), not a native