Added timezone for user; Moved dashboard to controller

This commit is contained in:
Constantin Graf
2024-03-19 17:19:54 +01:00
parent 2133eb0c16
commit 6ecfef6bf1
16 changed files with 494 additions and 308 deletions

View File

@@ -23,6 +23,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'timezone' => ['required', 'timezone:all'],
])->validateWithBag('updateProfileInformation'); ])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) { if (isset($input['photo'])) {
@@ -36,6 +37,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->forceFill([ $user->forceFill([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'timezone' => $input['timezone'],
])->save(); ])->save();
} }
} }

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
abstract class Controller extends \App\Http\Controllers\Controller
{
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Models\User;
use App\Service\DashboardService;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function dashboard(DashboardService $dashboardService): Response
{
/** @var User $user */
$user = auth()->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,
]);
}
}

View File

@@ -25,6 +25,7 @@ use Laravel\Passport\HasApiTokens;
* @property string $email * @property string $email
* @property string|null $email_verified_at * @property string|null $email_verified_at
* @property string|null $password * @property string|null $password
* @property string $timezone
* @property bool $is_placeholder * @property bool $is_placeholder
* @property Collection<Organization> $organizations * @property Collection<Organization> $organizations
* @property Collection<TimeEntry> $timeEntries * @property Collection<TimeEntry> $timeEntries

View File

@@ -13,6 +13,8 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\UpdateOrganization;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\TimezoneService;
use Illuminate\Http\Request;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -112,5 +114,14 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::role('placeholder', 'Placeholder', [ Jetstream::role('placeholder', 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); ])->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(),
]);
}
);
} }
} }

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\TimeEntry;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonTimeZone;
use Illuminate\Support\Facades\DB;
class DashboardService
{
/**
* @return array<int, string>
*/
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<int, array{0: string, 1: int}>
*/
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<int, array{date: string, duration: int}>
*/
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,
],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Carbon\CarbonTimeZone;
use DateTimeZone;
class TimezoneService
{
/**
* @return array<string>
*/
public function getTimezones(): array
{
$tzlist = CarbonTimeZone::listIdentifiers(DateTimeZone::ALL);
return $tzlist;
}
/**
* @return array<string, string>
*/
public function getSelectOptions(): array
{
$tzlist = $this->getTimezones();
$options = [];
foreach ($tzlist as $tz) {
$options[$tz] = $tz;
}
return $options;
}
}

View File

@@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [ return [
@@ -74,5 +76,8 @@ return [
RestrictedDocsAccess::class, RestrictedDocsAccess::class,
], ],
'extensions' => [], 'extensions' => [
ApiExceptionTypeToSchema::class,
PaginatedResourceCollectionTypeToSchema::class,
],
]; ];

View File

@@ -32,9 +32,19 @@ class UserFactory extends Factory
'profile_photo_path' => null, 'profile_photo_path' => null,
'current_team_id' => null, 'current_team_id' => null,
'is_placeholder' => false, '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 public function placeholder(bool $placeholder = true): static
{ {
return $this->state(function (array $attributes) use ($placeholder): array { return $this->state(function (array $attributes) use ($placeholder): array {

View File

@@ -23,6 +23,7 @@ return new class extends Migration
$table->boolean('is_placeholder')->default(false); $table->boolean('is_placeholder')->default(false);
$table->foreignUuid('current_team_id')->nullable(); $table->foreignUuid('current_team_id')->nullable();
$table->string('profile_photo_path', 2048)->nullable(); $table->string('profile_photo_path', 2048)->nullable();
$table->string('timezone')->nullable();
$table->timestamps(); $table->timestamps();
$table->uniqueIndex('email') $table->uniqueIndex('email')

View File

@@ -20,6 +20,7 @@ const form = useForm({
name: props.user.name, name: props.user.name,
email: props.user.email, email: props.user.email,
photo: null as File | null, photo: null as File | null,
timezone: props.user.timezone,
}); });
const verificationLinkSent = ref<boolean | null>(null); const verificationLinkSent = ref<boolean | null>(null);
@@ -198,6 +199,17 @@ const page = usePage<{
</div> </div>
</div> </div>
</div> </div>
<!-- Timezone -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="timezone" value="Timezone" />
<select name="timezone" id="timezone" v-model="form.timezone" class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
<option v-for="timezone in $page.props.timezones" :value="timezone">
{{ timezone }}
</option>
</select>
<InputError :message="form.errors.timezone" class="mt-2" />
</div>
</template> </template>
<template #actions> <template #actions>

View File

@@ -110,6 +110,7 @@ export interface User {
two_factor_secret?: string | null; two_factor_secret?: string | null;
two_factor_recovery_codes?: string | null; two_factor_recovery_codes?: string | null;
two_factor_confirmed_at: string | null; two_factor_confirmed_at: string | null;
timezone: string;
// mutators // mutators
profile_photo_url: string; profile_photo_url: string;
// relations // relations

View File

@@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\Web\DashboardController;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
/* /*
@@ -32,309 +32,5 @@ Route::middleware([
config('jetstream.auth_session'), config('jetstream.auth_session'),
'verified', 'verified',
])->group(function () { ])->group(function () {
Route::get('/dashboard', function () { Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard');
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' => [
// not really sure how many days we need here but probably around 60
// the second value is the duration in seconds
['2024-01-21', 10],
['2024-01-22', 10],
['2024-01-23', 20],
['2024-01-24', 10],
['2024-01-25', 10],
['2024-01-26', 10],
['2024-01-27', 20],
['2024-01-28', 10],
['2024-01-29', 20],
['2024-01-30', 10],
['2024-01-31', 10],
['2024-02-01', 20],
['2024-02-02', 20],
['2024-02-03', 10],
['2024-02-04', 30],
['2024-02-05', 10],
['2024-02-06', 20],
['2024-02-07', 10],
['2024-02-08', 30],
['2024-02-09', 10],
['2024-02-10', 10],
['2024-02-11', 10],
['2024-02-12', 10],
['2024-02-13', 10],
['2024-02-14', 20],
['2024-02-15', 10],
['2024-02-16', 10],
['2024-02-17', 10],
['2024-02-18', 10],
['2024-02-19', 10],
['2024-02-20', 30],
['2024-02-21', 20],
['2024-02-22', 20],
['2024-02-23', 30],
['2024-02-24', 20],
['2024-02-25', 10],
['2024-02-26', 10],
['2024-02-27', 10],
['2024-02-28', 20],
['2024-02-29', 10],
['2024-03-01', 10],
['2024-03-02', 20],
['2024-03-03', 10],
['2024-03-04', 30],
['2024-03-05', 10],
['2024-03-06', 20],
['2024-03-07', 30],
['2024-03-08', 10],
['2024-03-09', 20],
['2024-03-10', 10],
['2024-03-11', 10],
['2024-03-12', 10],
['2024-03-13', 10],
['2024-03-14', 10],
['2024-03-15', 10],
['2024-03-16', 10],
['2024-03-17', 10],
['2024-03-18', 10],
['2024-03-19', 10],
['2024-03-20', 10],
['2024-03-21', 10],
['2024-03-22', 10],
['2024-03-23', 10],
['2024-03-24', 10],
['2024-03-25', 10],
['2024-03-26', 10],
['2024-03-27', 10],
['2024-03-28', 10],
['2024-03-29', 10],
['2024-03-30', 10],
['2024-03-31', 10],
],
'totalWeeklyTime' => 400,
'totalWeeklyBillableTime' => 300,
'totalWeeklyBillableAmount' => [
'value' => 300.5,
'currency' => 'USD',
],
'weeklyHistory' => [
// statistics for the current week starting at Monday / Sunday
[
'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,
],
],
]);
})->name('dashboard');
}); });

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\User; use App\Models\User;
use App\Service\TimezoneService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -14,13 +15,18 @@ class ProfileInformationTest extends TestCase
public function test_profile_information_can_be_updated(): void public function test_profile_information_can_be_updated(): void
{ {
$this->actingAs($user = User::factory()->create()); // Arrange
$user = User::factory()->create();
$this->actingAs($user);
// Act
$response = $this->put('/user/profile-information', [ $response = $this->put('/user/profile-information', [
'name' => 'Test Name', 'name' => 'Test Name',
'email' => 'test@example.com', 'email' => 'test@example.com',
'timezone' => app(TimezoneService::class)->getTimezones()[0],
]); ]);
// Assert
$this->assertEquals('Test Name', $user->fresh()->name); $this->assertEquals('Test Name', $user->fresh()->name);
$this->assertEquals('test@example.com', $user->fresh()->email); $this->assertEquals('test@example.com', $user->fresh()->email);
} }

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\DashboardService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardServiceTest extends TestCase
{
use RefreshDatabase;
public function test_daily_tracked_hours_returns_correct_values(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'UTC'));
$user = User::factory()->create([
'timezone' => 'Europe/Vienna',
]);
$timeEntry = TimeEntry::factory()->forUser($user)->create([
'start' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'),
'end' => Carbon::create(2023, 12, 31, 0, 0, 40, 'UTC'),
]);
// Act
$service = new DashboardService();
$result = $service->getDailyTrackedHours($user, 5);
// Assert
$this->assertSame([
['2024-01-01', 0],
['2023-12-31', 40],
['2023-12-30', 0],
['2023-12-29', 0],
['2023-12-28', 0],
], $result);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use Tests\TestCase;
class TimezoneServiceTest extends TestCase
{
public function test_get_timezones_returns_all_available_timezones(): void
{
// Arrange
$service = new \App\Service\TimezoneService();
// Act
$result = $service->getTimezones();
// Assert
$this->assertIsArray($result);
$this->assertCount(419, $result);
$this->assertContains('Europe/Vienna', $result);
$this->assertContains('Europe/Berlin', $result);
$this->assertContains('Europe/London', $result);
}
}