Added week start

This commit is contained in:
Constantin Graf
2024-03-21 13:53:05 +01:00
parent fb04cff7c1
commit fc5b35661d
15 changed files with 364 additions and 67 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -24,6 +25,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'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'], 'timezone' => ['required', 'timezone:all'],
'week_start' => ['required', Rule::enum(Weekday::class)],
])->validateWithBag('updateProfileInformation'); ])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) { if (isset($input['photo'])) {
@@ -38,6 +40,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'timezone' => $input['timezone'], 'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->save(); ])->save();
} }
} }

47
app/Enums/Weekday.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Illuminate\Support\Carbon;
enum Weekday: string
{
case Monday = 'monday';
case Tuesday = 'tuesday';
case Wednesday = 'wednesday';
case Thursday = 'thursday';
case Friday = 'friday';
case Saturday = 'saturday';
case Sunday = 'sunday';
public function carbonWeekDay(): int
{
return match ($this) {
Weekday::Monday => 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<string, string>
*/
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),
];
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Enums\Weekday;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Filament\Panel; use Filament\Panel;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -27,6 +28,7 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $password * @property string|null $password
* @property string $timezone * @property string $timezone
* @property bool $is_placeholder * @property bool $is_placeholder
* @property Weekday $week_start
* @property Collection<Organization> $organizations * @property Collection<Organization> $organizations
* @property Collection<TimeEntry> $timeEntries * @property Collection<TimeEntry> $timeEntries
* *
@@ -79,6 +81,16 @@ class User extends Authenticatable
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'is_placeholder' => 'boolean', 'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
];
/**
* The model's default values for attributes.
*
* @var array<string, mixed>
*/
protected $attributes = [
'week_start' => Weekday::Monday,
]; ];
/** /**

View File

@@ -20,6 +20,7 @@ use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;

View File

@@ -11,6 +11,7 @@ use App\Actions\Jetstream\DeleteUser;
use App\Actions\Jetstream\InviteOrganizationMember; use App\Actions\Jetstream\InviteOrganizationMember;
use App\Actions\Jetstream\RemoveOrganizationMember; use App\Actions\Jetstream\RemoveOrganizationMember;
use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\UpdateOrganization;
use App\Enums\Weekday;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\TimezoneService; use App\Service\TimezoneService;
@@ -120,6 +121,7 @@ class JetstreamServiceProvider extends ServiceProvider
function (Request $request, array $data) { function (Request $request, array $data) {
return array_merge($data, [ return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]); ]);
} }
); );

View File

@@ -4,40 +4,92 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Weekday;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonTimeZone; use Carbon\CarbonTimeZone;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class DashboardService class DashboardService
{ {
/** private TimezoneService $timezoneService;
* @return array<int, string>
*/ public function __construct(TimezoneService $timezoneService)
private function lastDays(int $days, CarbonTimeZone $timeZone): array
{ {
$result = []; $this->timezoneService = $timezoneService;
$date = Carbon::now($timeZone); }
/**
* @return Collection<int, string>
*/
private function lastDays(int $days, CarbonTimeZone $timeZone): Collection
{
$result = new Collection();
$date = Carbon::now($timeZone)->subDays($days);
for ($i = 0; $i < $days; $i++) { for ($i = 0; $i < $days; $i++) {
$result[] = $date->format('Y-m-d'); $date->addDay();
$date = $date->subDay(); $result->push($date->format('Y-m-d'));
} }
return $result; return $result;
} }
/**
* @return Collection<int, string>
*/
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<int, string> $possibleDates
* @param Builder<TimeEntry> $builder
* @return Builder<TimeEntry>
*/
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 * Get the daily tracked hours for the user
* First value: date * First value: date
* Second value: seconds * Second value: seconds
* *
* @return array<int, array{0: string, 1: int}> * @return array<int, array{date: string, duration: int}>
*/ */
public function getDailyTrackedHours(User $user, int $days): array public function getDailyTrackedHours(User $user, int $days): array
{ {
$timezone = new CarbonTimeZone($user->timezone); $timezone = $this->timezoneService->getTimezoneFromUser($user);
$timezoneShift = $timezone->getOffset(new \DateTime('now', new \DateTimeZone('UTC'))); $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
if ($timezoneShift > 0) { if ($timezoneShift > 0) {
$dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\'';
@@ -47,60 +99,67 @@ class DashboardService
$dateWithTimeZone = 'start'; $dateWithTimeZone = 'start';
} }
$resultDb = TimeEntry::query() $possibleDays = $this->lastDays($days, $timezone);
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as value'))
$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) ->where('user_id', '=', $user->id)
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date') ->orderBy('date');
->get()
->pluck('value', 'date'); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
$resultDb = $query->get()
->pluck('aggregate', 'date');
$result = []; $result = [];
$lastDays = $this->lastDays($days, $timezone);
foreach ($lastDays as $day) { foreach ($possibleDays as $possibleDay) {
$result[] = [$day, (int) ($resultDb->get($day) ?? 0)]; $result[] = [
'date' => $possibleDay,
'duration' => (int) ($resultDb->get($possibleDay) ?? 0),
];
} }
return $result; return $result;
} }
/** /**
* Statistics for the current week starting at Monday / Sunday * Statistics for the current week starting at weekday of users preference
* *
* @return array<int, array{date: string, duration: int}> * @return array<int, array{date: string, duration: int}>
*/ */
public function getWeeklyHistory(User $user): array public function getWeeklyHistory(User $user): array
{ {
return [ $timezone = $this->timezoneService->getTimezoneFromUser($user);
[ $timezoneShift = $this->timezoneService->getShiftFromUtc($timezone);
'date' => '2024-02-26', if ($timezoneShift > 0) {
'duration' => 3600, $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\'';
], } elseif ($timezoneShift < 0) {
[ $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\'';
'date' => '2024-02-27', } else {
'duration' => 2000, $dateWithTimeZone = 'start';
], }
[ $possibleDays = $this->daysOfThisWeek($timezone, $user->week_start);
'date' => '2024-02-28',
'duration' => 4000, $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)
'date' => '2024-02-29', ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
'duration' => 3000, ->orderBy('date');
],
[ $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
'date' => '2024-03-01', $resultDb = $query->get()
'duration' => 5000, ->pluck('aggregate', 'date');
],
[ $result = [];
'date' => '2024-03-02',
'duration' => 3000, foreach ($possibleDays as $possibleDay) {
], $result[] = [
[ 'date' => $possibleDay,
'date' => '2024-03-03', 'duration' => (int) ($resultDb->get($possibleDay) ?? 0),
'duration' => 2000, ];
], }
];
return $result;
} }
} }

View File

@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Models\User;
use Carbon\CarbonTimeZone; use Carbon\CarbonTimeZone;
use DateTime;
use DateTimeZone; use DateTimeZone;
use Illuminate\Support\Facades\Log;
class TimezoneService class TimezoneService
{ {
@@ -19,6 +22,20 @@ class TimezoneService
return $tzlist; 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<string, string> * @return array<string, string>
*/ */
@@ -37,4 +54,11 @@ class TimezoneService
{ {
return in_array($timezone, $this->getTimezones(), true); return in_array($timezone, $this->getTimezones(), true);
} }
public function getShiftFromUtc(CarbonTimeZone $timeZone): int
{
$timezoneShift = $timeZone->getOffset(new DateTime('now', new DateTimeZone('UTC')));
return $timezoneShift;
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Database\Factories; namespace Database\Factories;
use App\Enums\Weekday;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@@ -33,6 +34,7 @@ class UserFactory extends Factory
'current_team_id' => null, 'current_team_id' => null,
'is_placeholder' => false, 'is_placeholder' => false,
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
'week_start' => Weekday::Monday,
]; ];
} }

View File

@@ -24,6 +24,15 @@ return new class extends Migration
$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'); $table->string('timezone');
$table->enum('week_start', [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]);
$table->timestamps(); $table->timestamps();
$table->uniqueIndex('email') $table->uniqueIndex('email')

19
lang/en/enum.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Enums\Weekday;
return [
'weekday' => [
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',
],
];

View File

@@ -8,3 +8,5 @@ parameters:
# Level 9 is the highest level # Level 9 is the highest level
level: 7 level: 7
checkOctaneCompatibility: true

View File

@@ -21,6 +21,7 @@ const form = useForm({
email: props.user.email, email: props.user.email,
photo: null as File | null, photo: null as File | null,
timezone: props.user.timezone, timezone: props.user.timezone,
week_start: props.user.week_start,
}); });
const verificationLinkSent = ref<boolean | null>(null); const verificationLinkSent = ref<boolean | null>(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"> class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a Timezone</option> <option value="" disabled>Select a Timezone</option>
<option <option
v-for="timezone in $page.props.timezones" v-for="(timezoneTranslated, timezoneKey) in $page.props
:key="timezone" .timezones"
:value="timezone"> :key="timezoneKey"
{{ timezone }} :value="timezoneKey">
{{ timezoneTranslated }}
</option> </option>
</select> </select>
<InputError :message="form.errors.timezone" class="mt-2" /> <InputError :message="form.errors.timezone" class="mt-2" />
</div> </div>
<!-- Week start -->
<div class="col-span-6 sm:col-span-4">
<InputLabel for="week_start" value="Start of the week" />
<select
name="week_start"
id="week_start"
v-model="form.week_start"
required
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
<option value="" disabled>Select a week day</option>
<option
v-for="(weekdayTranslated, weekdayKey) in $page.props
.weekdays"
:key="weekdayKey"
:value="weekdayKey">
{{ weekdayTranslated }}
</option>
</select>
<InputError :message="form.errors.week_start" class="mt-2" />
</div>
</template> </template>
<template #actions> <template #actions>

View File

@@ -111,6 +111,7 @@ export interface User {
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; timezone: string;
week_start: string;
// mutators // mutators
profile_photo_url: string; profile_photo_url: string;
// relations // relations

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\Weekday;
use App\Models\User; use App\Models\User;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -24,10 +25,15 @@ class ProfileInformationTest extends TestCase
'name' => 'Test Name', 'name' => 'Test Name',
'email' => 'test@example.com', 'email' => 'test@example.com',
'timezone' => app(TimezoneService::class)->getTimezones()[0], 'timezone' => app(TimezoneService::class)->getTimezones()[0],
'week_start' => Weekday::Sunday->value,
]); ]);
// Assert // Assert
$this->assertEquals('Test Name', $user->fresh()->name); $response->assertValid(errorBag: 'updateProfileInformation');
$this->assertEquals('test@example.com', $user->fresh()->email); $user = $user->fresh();
$this->assertEquals('Test Name', $user->name);
$this->assertEquals('test@example.com', $user->email);
$this->assertEquals(app(TimezoneService::class)->getTimezones()[0], $user->timezone);
$this->assertEquals(Weekday::Sunday, $user->week_start);
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Weekday;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\DashboardService; use App\Service\DashboardService;
@@ -15,29 +16,115 @@ class DashboardServiceTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected DashboardService $dashboardService;
public function setUp(): void
{
parent::setUp();
$this->dashboardService = app(DashboardService::class);
}
public function test_daily_tracked_hours_returns_correct_values(): void public function test_daily_tracked_hours_returns_correct_values(): void
{ {
// Arrange // Arrange
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'UTC')); $this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
$user = User::factory()->create([ $user = User::factory()->create([
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
]); ]);
$timeEntry = TimeEntry::factory()->forUser($user)->create([ $timeEntry1 = TimeEntry::factory()->forUser($user)->create([
'start' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'), // Note: The start time shifts in timezone Europe/Vienna to the next day
'end' => Carbon::create(2023, 12, 31, 0, 0, 40, 'UTC'), 'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
]);
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
]); ]);
// Act // Act
$service = new DashboardService(); $result = $this->dashboardService->getDailyTrackedHours($user, 5);
$result = $service->getDailyTrackedHours($user, 5);
// Assert // Assert
$this->assertSame([ $this->assertSame([
['2024-01-01', 0], [
['2023-12-31', 40], 'date' => '2023-12-28',
['2023-12-30', 0], 'duration' => 0,
['2023-12-29', 0], ],
['2023-12-28', 0], [
'date' => '2023-12-29',
'duration' => 0,
],
[
'date' => '2023-12-30',
'duration' => 40,
],
[
'date' => '2023-12-31',
'duration' => 40,
],
[
'date' => '2024-01-01',
'duration' => 0,
],
], $result);
}
public function test_weekly_history_returns_correct_values(): void
{
// Arrange
// Note: Is a Monday
$this->travelTo(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/Vienna'));
$user = User::factory()->create([
'timezone' => 'Europe/Vienna',
'week_start' => Weekday::Sunday,
]);
// Note: This is a Sunday
$timeEntry1 = TimeEntry::factory()->forUser($user)->create([
// Note: The start time shifts in timezone Europe/Vienna to the next day
'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'),
'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'),
]);
// Note: This is a Saturday
$timeEntry2 = TimeEntry::factory()->forUser($user)->create([
// Note: The start time NOT shifts in timezone Europe/Vienna to the next day
'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'),
'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'),
]);
// Act
$result = $this->dashboardService->getWeeklyHistory($user);
// Assert
$this->assertSame([
[
'date' => '2023-12-31',
'duration' => 40,
],
[
'date' => '2024-01-01',
'duration' => 0,
],
[
'date' => '2024-01-02',
'duration' => 0,
],
[
'date' => '2024-01-03',
'duration' => 0,
],
[
'date' => '2024-01-04',
'duration' => 0,
],
[
'date' => '2024-01-05',
'duration' => 0,
],
[
'date' => '2024-01-06',
'duration' => 0,
],
], $result); ], $result);
} }
} }