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;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
@@ -24,6 +25,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'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 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'name' => $input['name'],
'email' => $input['email'],
'timezone' => $input['timezone'],
'week_start' => $input['week_start'],
])->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;
use App\Enums\Weekday;
use Database\Factories\UserFactory;
use Filament\Panel;
use Illuminate\Database\Eloquent\Builder;
@@ -27,6 +28,7 @@ use Laravel\Passport\HasApiTokens;
* @property string|null $password
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property Collection<Organization> $organizations
* @property Collection<TimeEntry> $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<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 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;

View File

@@ -11,6 +11,7 @@ use App\Actions\Jetstream\DeleteUser;
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 @@ class JetstreamServiceProvider extends ServiceProvider
function (Request $request, array $data) {
return array_merge($data, [
'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(),
'weekdays' => Weekday::toSelectArray(),
]);
}
);

View File

@@ -4,40 +4,92 @@ declare(strict_types=1);
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
{
/**
* @return array<int, string>
*/
private function lastDays(int $days, CarbonTimeZone $timeZone): array
private TimezoneService $timezoneService;
public function __construct(TimezoneService $timezoneService)
{
$result = [];
$date = Carbon::now($timeZone);
$this->timezoneService = $timezoneService;
}
/**
* @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++) {
$result[] = $date->format('Y-m-d');
$date = $date->subDay();
$date->addDay();
$result->push($date->format('Y-m-d'));
}
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
* First value: date
* 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
{
$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 @@ class DashboardService
$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<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,
],
];
$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;
}
}

View File

@@ -4,8 +4,11 @@ declare(strict_types=1);
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 @@ class TimezoneService
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>
*/
@@ -37,4 +54,11 @@ class TimezoneService
{
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;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -33,6 +34,7 @@ class UserFactory extends Factory
'current_team_id' => null,
'is_placeholder' => false,
'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->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')

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: 7
checkOctaneCompatibility: true

View File

@@ -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<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">
<option value="" disabled>Select a Timezone</option>
<option
v-for="timezone in $page.props.timezones"
:key="timezone"
:value="timezone">
{{ timezone }}
v-for="(timezoneTranslated, timezoneKey) in $page.props
.timezones"
:key="timezoneKey"
:value="timezoneKey">
{{ timezoneTranslated }}
</option>
</select>
<InputError :message="form.errors.timezone" class="mt-2" />
</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 #actions>

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Weekday;
use App\Models\User;
use App\Service\TimezoneService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -24,10 +25,15 @@ class ProfileInformationTest extends TestCase
'name' => 'Test Name',
'email' => 'test@example.com',
'timezone' => app(TimezoneService::class)->getTimezones()[0],
'week_start' => Weekday::Sunday->value,
]);
// Assert
$this->assertEquals('Test Name', $user->fresh()->name);
$this->assertEquals('test@example.com', $user->fresh()->email);
$response->assertValid(errorBag: 'updateProfileInformation');
$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;
use App\Enums\Weekday;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\DashboardService;
@@ -15,29 +16,115 @@ class DashboardServiceTest extends TestCase
{
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
{
// 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([
'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'),
$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'),
]);
$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
$service = new DashboardService();
$result = $service->getDailyTrackedHours($user, 5);
$result = $this->dashboardService->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],
[
'date' => '2023-12-28',
'duration' => 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);
}
}