From 5cd08ef3a759ffa1a3bd87c810f9e60f96d03797 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:53:05 +0100 Subject: [PATCH] Added week start --- .../Fortify/UpdateUserProfileInformation.php | 3 + app/Enums/Weekday.php | 47 ++++++ app/Models/User.php | 12 ++ app/Providers/AppServiceProvider.php | 1 + app/Providers/JetstreamServiceProvider.php | 2 + app/Service/DashboardService.php | 155 ++++++++++++------ app/Service/TimezoneService.php | 24 +++ database/factories/UserFactory.php | 2 + .../2014_10_12_000000_create_users_table.php | 9 + lang/en/enum.php | 19 +++ phpstan.neon | 2 + .../Partials/UpdateProfileInformationForm.vue | 31 +++- resources/js/types/models.ts | 1 + tests/Feature/ProfileInformationTest.php | 10 +- tests/Unit/Service/DashboardServiceTest.php | 109 ++++++++++-- 15 files changed, 362 insertions(+), 65 deletions(-) create mode 100644 app/Enums/Weekday.php create mode 100644 lang/en/enum.php diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index c521b558..feee9aef 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -4,6 +4,7 @@ namespace App\Actions\Fortify; +use App\Enums\Weekday; use App\Models\User; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Support\Facades\Validator; @@ -24,6 +25,7 @@ public function update(User $user, array $input): void 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 'timezone' => ['required', 'timezone:all'], + 'week_start' => ['required', Rule::enum(Weekday::class)], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { @@ -38,6 +40,7 @@ public function update(User $user, array $input): void 'name' => $input['name'], 'email' => $input['email'], 'timezone' => $input['timezone'], + 'week_start' => $input['week_start'], ])->save(); } } diff --git a/app/Enums/Weekday.php b/app/Enums/Weekday.php new file mode 100644 index 00000000..8181b5c2 --- /dev/null +++ b/app/Enums/Weekday.php @@ -0,0 +1,47 @@ + Carbon::MONDAY, + Weekday::Tuesday => Carbon::TUESDAY, + Weekday::Wednesday => Carbon::WEDNESDAY, + Weekday::Thursday => Carbon::THURSDAY, + Weekday::Friday => Carbon::FRIDAY, + Weekday::Saturday => Carbon::SATURDAY, + Weekday::Sunday => Carbon::SUNDAY, + }; + } + + /** + * @return array + */ + public static function toSelectArray(): array + { + return [ + Weekday::Monday->value => __('enum.weekday.'.Weekday::Monday->value), + Weekday::Tuesday->value => __('enum.weekday.'.Weekday::Tuesday->value), + Weekday::Wednesday->value => __('enum.weekday.'.Weekday::Wednesday->value), + Weekday::Thursday->value => __('enum.weekday.'.Weekday::Thursday->value), + Weekday::Friday->value => __('enum.weekday.'.Weekday::Friday->value), + Weekday::Saturday->value => __('enum.weekday.'.Weekday::Saturday->value), + Weekday::Sunday->value => __('enum.weekday.'.Weekday::Sunday->value), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index cff3114a..9f1891e1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\Weekday; use Database\Factories\UserFactory; use Filament\Panel; use Illuminate\Database\Eloquent\Builder; @@ -27,6 +28,7 @@ * @property string|null $password * @property string $timezone * @property bool $is_placeholder + * @property Weekday $week_start * @property Collection $organizations * @property Collection $timeEntries * @@ -79,6 +81,16 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', 'is_placeholder' => 'boolean', + 'week_start' => Weekday::class, + ]; + + /** + * The model's default values for attributes. + * + * @var array + */ + protected $attributes = [ + 'week_start' => Weekday::Monday, ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2674c1bf..03cc5975 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ use Filament\Forms\Components\Section; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 18905a84..3e9af87d 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -11,6 +11,7 @@ use App\Actions\Jetstream\InviteOrganizationMember; use App\Actions\Jetstream\RemoveOrganizationMember; use App\Actions\Jetstream\UpdateOrganization; +use App\Enums\Weekday; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Service\TimezoneService; @@ -120,6 +121,7 @@ protected function configurePermissions(): void function (Request $request, array $data) { return array_merge($data, [ 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), + 'weekdays' => Weekday::toSelectArray(), ]); } ); diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 616d530e..dfff7fdb 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -4,40 +4,92 @@ namespace App\Service; +use App\Enums\Weekday; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; use Carbon\CarbonTimeZone; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class DashboardService { + private TimezoneService $timezoneService; + + public function __construct(TimezoneService $timezoneService) + { + $this->timezoneService = $timezoneService; + } + /** - * @return array + * @return Collection */ - private function lastDays(int $days, CarbonTimeZone $timeZone): array + private function lastDays(int $days, CarbonTimeZone $timeZone): Collection { - $result = []; - $date = Carbon::now($timeZone); + $result = new Collection(); + $date = Carbon::now($timeZone)->subDays($days); for ($i = 0; $i < $days; $i++) { - $result[] = $date->format('Y-m-d'); - $date = $date->subDay(); + $date->addDay(); + $result->push($date->format('Y-m-d')); + } + + return $result; + } + + /** + * @return Collection + */ + private function daysOfThisWeek(CarbonTimeZone $timeZone, Weekday $weekday): Collection + { + $result = new Collection(); + $date = Carbon::now($timeZone); + $start = $date->startOfWeek($weekday->carbonWeekDay()); + for ($i = 0; $i < 7; $i++) { + $result->push($start->format('Y-m-d')); + $start->addDay(); } return $result; } + /** + * @param Collection $possibleDates + * @param Builder $builder + * @return Builder + */ + private function constrainDateByPossibleDates(Builder $builder, Collection $possibleDates, CarbonTimeZone $timeZone): Builder + { + $value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone); + $value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone); + if ($value2 === false || $value1 === false) { + throw new \RuntimeException('Provided date is not valid'); + } + if ($value1->gt($value2)) { + $last = $value1; + $first = $value2; + } else { + $last = $value2; + $first = $value1; + } + + return $builder->whereBetween('start', [ + $first->startOfDay()->utc(), + $last->endOfDay()->utc(), + ]); + } + /** * Get the daily tracked hours for the user * First value: date * Second value: seconds * - * @return array + * @return array */ public function getDailyTrackedHours(User $user, int $days): array { - $timezone = new CarbonTimeZone($user->timezone); - $timezoneShift = $timezone->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))); + $timezone = $this->timezoneService->getTimezoneFromUser($user); + $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone); if ($timezoneShift > 0) { $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; @@ -47,60 +99,67 @@ public function getDailyTrackedHours(User $user, int $days): array $dateWithTimeZone = 'start'; } - $resultDb = TimeEntry::query() - ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as value')) + $possibleDays = $this->lastDays($days, $timezone); + + $query = TimeEntry::query() + ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->where('user_id', '=', $user->id) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) - ->orderBy('date') - ->get() - ->pluck('value', 'date'); + ->orderBy('date'); + + $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); + $resultDb = $query->get() + ->pluck('aggregate', 'date'); $result = []; - $lastDays = $this->lastDays($days, $timezone); - foreach ($lastDays as $day) { - $result[] = [$day, (int) ($resultDb->get($day) ?? 0)]; + foreach ($possibleDays as $possibleDay) { + $result[] = [ + 'date' => $possibleDay, + 'duration' => (int) ($resultDb->get($possibleDay) ?? 0), + ]; } return $result; } /** - * Statistics for the current week starting at Monday / Sunday + * Statistics for the current week starting at weekday of users preference * * @return array */ public function getWeeklyHistory(User $user): array { - return [ - [ - 'date' => '2024-02-26', - 'duration' => 3600, - ], - [ - 'date' => '2024-02-27', - 'duration' => 2000, - ], - [ - 'date' => '2024-02-28', - 'duration' => 4000, - ], - [ - 'date' => '2024-02-29', - 'duration' => 3000, - ], - [ - 'date' => '2024-03-01', - 'duration' => 5000, - ], - [ - 'date' => '2024-03-02', - 'duration' => 3000, - ], - [ - 'date' => '2024-03-03', - 'duration' => 2000, - ], - ]; + $timezone = $this->timezoneService->getTimezoneFromUser($user); + $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone); + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start); + + $query = TimeEntry::query() + ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) + ->where('user_id', '=', $user->id) + ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) + ->orderBy('date'); + + $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); + $resultDb = $query->get() + ->pluck('aggregate', 'date'); + + $result = []; + + foreach ($possibleDays as $possibleDay) { + $result[] = [ + 'date' => $possibleDay, + 'duration' => (int) ($resultDb->get($possibleDay) ?? 0), + ]; + } + + return $result; } } diff --git a/app/Service/TimezoneService.php b/app/Service/TimezoneService.php index d3a62509..26267e67 100644 --- a/app/Service/TimezoneService.php +++ b/app/Service/TimezoneService.php @@ -4,8 +4,11 @@ namespace App\Service; +use App\Models\User; use Carbon\CarbonTimeZone; +use DateTime; use DateTimeZone; +use Illuminate\Support\Facades\Log; class TimezoneService { @@ -19,6 +22,20 @@ public function getTimezones(): array return $tzlist; } + public function getTimezoneFromUser(User $user): CarbonTimeZone + { + try { + return new CarbonTimeZone($user->timezone); + } catch (\Exception $e) { + Log::error('User has a invalid timezone', [ + 'user_id' => $user->getKey(), + 'timezone' => $user->timezone, + ]); + + return new CarbonTimeZone('UTC'); + } + } + /** * @return array */ @@ -37,4 +54,11 @@ public function isValid(string $timezone): bool { return in_array($timezone, $this->getTimezones(), true); } + + public function getShiftFromUtc(CarbonTimeZone $timeZone): int + { + $timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC'))); + + return $timezoneShift; + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 070c647b..ef7ba075 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\Weekday; use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -33,6 +34,7 @@ public function definition(): array 'current_team_id' => null, 'is_placeholder' => false, 'timezone' => 'Europe/Vienna', + 'week_start' => Weekday::Monday, ]; } diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index f6cd3bc7..b9cd5898 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -24,6 +24,15 @@ public function up(): void $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); $table->string('timezone'); + $table->enum('week_start', [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ]); $table->timestamps(); $table->uniqueIndex('email') diff --git a/lang/en/enum.php b/lang/en/enum.php new file mode 100644 index 00000000..6fb1cf7b --- /dev/null +++ b/lang/en/enum.php @@ -0,0 +1,19 @@ + [ + Weekday::Monday->value => 'Monday', + Weekday::Tuesday->value => 'Tuesday', + Weekday::Wednesday->value => 'Wednesday', + Weekday::Thursday->value => 'Thursday', + Weekday::Friday->value => 'Friday', + Weekday::Saturday->value => 'Saturday', + Weekday::Sunday->value => 'Sunday', + ], + +]; diff --git a/phpstan.neon b/phpstan.neon index 273e51aa..14f909b7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,3 +8,5 @@ parameters: # Level 9 is the highest level level: 7 + + checkOctaneCompatibility: true diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue index aad47e4c..7e38c313 100644 --- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue @@ -21,6 +21,7 @@ const form = useForm({ email: props.user.email, photo: null as File | null, timezone: props.user.timezone, + week_start: props.user.week_start, }); const verificationLinkSent = ref(null); @@ -211,14 +212,36 @@ const page = usePage<{ class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm"> + + +
+ + + +