diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 538e91b1..c521b558 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -23,6 +23,7 @@ public function update(User $user, array $input): void 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'timezone' => ['required', 'timezone:all'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { @@ -36,6 +37,7 @@ public function update(User $user, array $input): void $user->forceFill([ 'name' => $input['name'], 'email' => $input['email'], + 'timezone' => $input['timezone'], ])->save(); } } diff --git a/app/Http/Controllers/Web/Controller.php b/app/Http/Controllers/Web/Controller.php new file mode 100644 index 00000000..82044cf2 --- /dev/null +++ b/app/Http/Controllers/Web/Controller.php @@ -0,0 +1,9 @@ +user(); + $dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, 60); + $weeklyHistory = $dashboardService->getWeeklyHistory($user); + + return Inertia::render('Dashboard', [ + 'weeklyProjectOverview' => [ + [ + 'value' => 120, + 'name' => 'Project 11', + 'color' => '#26a69a', + ], + [ + 'value' => 200, + 'name' => 'Project 2', + 'color' => '#d4e157', + ], + [ + 'value' => 150, + 'name' => 'Project 3', + 'color' => '#ff7043', + ], + ], + 'latestTasks' => [ + // the 4 tasks with the most recent time entries + [ + 'id' => Str::uuid(), + 'name' => 'Task 1', + 'project_name' => 'Research', + 'project_id' => Str::uuid(), + ], + [ + 'id' => Str::uuid(), + 'name' => 'Task 2', + 'project_name' => 'Research', + 'project_id' => Str::uuid(), + ], + [ + 'id' => Str::uuid(), + 'name' => 'Task 3', + 'project_name' => 'Research', + 'project_id' => Str::uuid(), + ], + [ + 'id' => Str::uuid(), + 'name' => 'Task 4', + 'project_name' => 'Research', + 'project_id' => Str::uuid(), + ], + ], + 'lastSevenDays' => [ + // the last 7 days with statistics for the time entries + [ + 'date' => '2024-02-26', + 'duration' => 3600, // in seconds + // if that is too difficult we can just skip that for now + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-25', + 'duration' => 7200, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-24', + 'duration' => 10800, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-23', + 'duration' => 14400, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-22', + 'duration' => 18000, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-21', + 'duration' => 21600, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + [ + 'date' => '2024-02-20', + 'duration' => 25200, // in seconds + 'history' => [ + // duration in s of the 3h windows for the day starting at 00:00 + 300, + 0, + 500, + 0, + 100, + 200, + 100, + 300, + ], + ], + + ], + 'latestTeamActivity' => [ + // the 4 most recently active members of your team with user_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working + [ + 'user_id' => Str::uuid(), + 'name' => 'John Doe', + 'description' => 'Working on the new feature', + 'time_entry_id' => Str::uuid(), + 'task_id' => Str::uuid(), + 'status' => true, + ], + [ + 'user_id' => Str::uuid(), + 'name' => 'Jane Doe', + 'description' => 'Working on the new feature', + 'time_entry_id' => Str::uuid(), + 'task_id' => Str::uuid(), + 'status' => false, + ], + [ + 'user_id' => Str::uuid(), + 'name' => 'John Smith', + 'description' => 'Working on the new feature', + 'time_entry_id' => Str::uuid(), + 'task_id' => Str::uuid(), + 'status' => true, + ], + [ + 'user_id' => Str::uuid(), + 'name' => 'Jane Smith', + 'description' => 'Working on the new feature', + 'time_entry_id' => Str::uuid(), + 'task_id' => Str::uuid(), + 'status' => false, + ], + ], + 'dailyTrackedHours' => $dailyTrackedHours, + 'totalWeeklyTime' => 400, + 'totalWeeklyBillableTime' => 300, + 'totalWeeklyBillableAmount' => [ + 'value' => 300.5, + 'currency' => 'USD', + ], + 'weeklyHistory' => $weeklyHistory, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a58a737f..cff3114a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,7 @@ * @property string $email * @property string|null $email_verified_at * @property string|null $password + * @property string $timezone * @property bool $is_placeholder * @property Collection $organizations * @property Collection $timeEntries diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index c9d6d59c..18905a84 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -13,6 +13,8 @@ use App\Actions\Jetstream\UpdateOrganization; use App\Models\Organization; use App\Models\OrganizationInvitation; +use App\Service\TimezoneService; +use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; use Laravel\Jetstream\Jetstream; @@ -112,5 +114,14 @@ protected function configurePermissions(): void Jetstream::role('placeholder', 'Placeholder', [ ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); + + Jetstream::inertia()->whenRendering( + 'Profile/Show', + function (Request $request, array $data) { + return array_merge($data, [ + 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), + ]); + } + ); } } diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php new file mode 100644 index 00000000..616d530e --- /dev/null +++ b/app/Service/DashboardService.php @@ -0,0 +1,106 @@ + + */ + private function lastDays(int $days, CarbonTimeZone $timeZone): array + { + $result = []; + $date = Carbon::now($timeZone); + for ($i = 0; $i < $days; $i++) { + $result[] = $date->format('Y-m-d'); + $date = $date->subDay(); + } + + return $result; + } + + /** + * Get the daily tracked hours for the user + * First value: date + * Second value: seconds + * + * @return array + */ + public function getDailyTrackedHours(User $user, int $days): array + { + $timezone = new CarbonTimeZone($user->timezone); + $timezoneShift = $timezone->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))); + + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + + $resultDb = TimeEntry::query() + ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as value')) + ->where('user_id', '=', $user->id) + ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) + ->orderBy('date') + ->get() + ->pluck('value', 'date'); + + $result = []; + $lastDays = $this->lastDays($days, $timezone); + + foreach ($lastDays as $day) { + $result[] = [$day, (int) ($resultDb->get($day) ?? 0)]; + } + + return $result; + } + + /** + * Statistics for the current week starting at Monday / Sunday + * + * @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, + ], + ]; + } +} diff --git a/app/Service/TimezoneService.php b/app/Service/TimezoneService.php new file mode 100644 index 00000000..27a43d56 --- /dev/null +++ b/app/Service/TimezoneService.php @@ -0,0 +1,35 @@ + + */ + public function getTimezones(): array + { + $tzlist = CarbonTimeZone::listIdentifiers(DateTimeZone::ALL); + + return $tzlist; + } + + /** + * @return array + */ + public function getSelectOptions(): array + { + $tzlist = $this->getTimezones(); + $options = []; + foreach ($tzlist as $tz) { + $options[$tz] = $tz; + } + + return $options; + } +} diff --git a/config/scramble.php b/config/scramble.php index eb01031c..08df2e44 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Extensions\Scramble\ApiExceptionTypeToSchema; +use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema; use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; return [ @@ -74,5 +76,8 @@ RestrictedDocsAccess::class, ], - 'extensions' => [], + 'extensions' => [ + ApiExceptionTypeToSchema::class, + PaginatedResourceCollectionTypeToSchema::class, + ], ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 893c7e65..070c647b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -32,9 +32,19 @@ public function definition(): array 'profile_photo_path' => null, 'current_team_id' => null, 'is_placeholder' => false, + 'timezone' => 'Europe/Vienna', ]; } + public function randomTimeZone(): static + { + return $this->state(function (array $attributes) { + return [ + 'timezone' => $this->faker->timezone(), + ]; + }); + } + public function placeholder(bool $placeholder = true): static { return $this->state(function (array $attributes) use ($placeholder): array { 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 ffa23ea9..3d08b16c 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -23,6 +23,7 @@ public function up(): void $table->boolean('is_placeholder')->default(false); $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); + $table->string('timezone')->nullable(); $table->timestamps(); $table->uniqueIndex('email') diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue index bb0e1e02..5defcf93 100644 --- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue @@ -20,6 +20,7 @@ const form = useForm({ name: props.user.name, email: props.user.email, photo: null as File | null, + timezone: props.user.timezone, }); const verificationLinkSent = ref(null); @@ -198,6 +199,17 @@ const page = usePage<{ + + +
+ + + +