Compare commits
No commits in common. "8fcd7fef420a6ee431f87fa3ca4c9872f678779e" and "8218de16f5cecc95b3940a682160d0e09c394d42" have entirely different histories.
8fcd7fef42
...
8218de16f5
20 changed files with 41 additions and 1142 deletions
|
|
@ -14,8 +14,6 @@ use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
|
||||||
use App\Schedule\UseCases\ListSchedules\ListSchedules;
|
use App\Schedule\UseCases\ListSchedules\ListSchedules;
|
||||||
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
|
||||||
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
|
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
|
||||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
|
|
||||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
|
|
||||||
use App\Shared\Http\RequestInput;
|
use App\Shared\Http\RequestInput;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -28,7 +26,6 @@ class ScheduleController extends Controller
|
||||||
private ListSchedules $listSchedules,
|
private ListSchedules $listSchedules,
|
||||||
private GetSchedule $getSchedule,
|
private GetSchedule $getSchedule,
|
||||||
private ListAssignmentsForDate $listAssignmentsForDate,
|
private ListAssignmentsForDate $listAssignmentsForDate,
|
||||||
private SetAssignmentCompletion $setAssignmentCompletion,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
|
|
@ -127,41 +124,6 @@ class ScheduleController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateAssignment(
|
|
||||||
Request $request,
|
|
||||||
int $assignmentId,
|
|
||||||
): JsonResponse {
|
|
||||||
$input = new RequestInput($request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$assignment = $this->setAssignmentCompletion->execute(
|
|
||||||
new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $assignmentId,
|
|
||||||
user: $this->user($request),
|
|
||||||
completed: $input->boolean('completed'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
return new JsonResponse(
|
|
||||||
['error' => $exception->getMessage()],
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'assignment' => [
|
|
||||||
'id' => $assignment->getId(),
|
|
||||||
'completedAt' => $assignment->getCompletedAt()
|
|
||||||
?->format(DATE_ATOM),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
@ -218,7 +180,7 @@ class ScheduleController extends Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{id: int, completedAt: string|null, element: array{
|
* @return array{id: int, element: array{
|
||||||
* name: string,
|
* name: string,
|
||||||
* kind: string,
|
* kind: string,
|
||||||
* path: list<string>
|
* path: list<string>
|
||||||
|
|
@ -229,8 +191,6 @@ class ScheduleController extends Controller
|
||||||
): array {
|
): array {
|
||||||
return [
|
return [
|
||||||
'id' => $assignment->getId(),
|
'id' => $assignment->getId(),
|
||||||
'completedAt' => $assignment->getCompletedAt()
|
|
||||||
?->format(DATE_ATOM),
|
|
||||||
'element' => [
|
'element' => [
|
||||||
'name' => $assignment->getName(),
|
'name' => $assignment->getName(),
|
||||||
'kind' => $assignment->getKind(),
|
'kind' => $assignment->getKind(),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ namespace App\Schedule;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use DomainException;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class EloquentScheduleRepository implements ScheduleRepository
|
class EloquentScheduleRepository implements ScheduleRepository
|
||||||
|
|
@ -30,7 +29,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
'scheduled_date' => $assignmentDto->scheduledDate
|
'scheduled_date' => $assignmentDto->scheduledDate
|
||||||
->format('Y-m-d'),
|
->format('Y-m-d'),
|
||||||
'position' => $assignmentDto->position,
|
'position' => $assignmentDto->position,
|
||||||
'completed_at' => null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +77,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
->where('schedule_assignments.scheduled_date', $date->format(
|
->where('schedule_assignments.scheduled_date', $date->format(
|
||||||
'Y-m-d',
|
'Y-m-d',
|
||||||
))
|
))
|
||||||
->whereNull('schedule_assignments.completed_at')
|
|
||||||
->with('schedule')
|
->with('schedule')
|
||||||
->orderByDesc('schedules.id')
|
->orderByDesc('schedules.id')
|
||||||
->orderBy('schedule_assignments.position')
|
->orderBy('schedule_assignments.position')
|
||||||
|
|
@ -98,43 +95,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
return $assignments;
|
return $assignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findAssignmentForUser(
|
|
||||||
int $id,
|
|
||||||
User $user,
|
|
||||||
): ?ScheduleAssignment {
|
|
||||||
$model = ScheduleAssignmentModel::query()
|
|
||||||
->select('schedule_assignments.*')
|
|
||||||
->join(
|
|
||||||
'schedules',
|
|
||||||
'schedules.id',
|
|
||||||
'=',
|
|
||||||
'schedule_assignments.schedule_id',
|
|
||||||
)
|
|
||||||
->where('schedule_assignments.id', $id)
|
|
||||||
->where('schedules.user_id', $user->getId())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
return $model === null
|
|
||||||
? null
|
|
||||||
: $this->assignmentToDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateAssignment(
|
|
||||||
ScheduleAssignment $assignment,
|
|
||||||
): ScheduleAssignment {
|
|
||||||
$model = ScheduleAssignmentModel::find($assignment->getId());
|
|
||||||
if ($model === null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"Assignment with id {$assignment->getId()} not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->completed_at = $assignment->getCompletedAt();
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
return $this->assignmentToDomain($model);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toDomain(ScheduleModel $model, User $user): Schedule
|
private function toDomain(ScheduleModel $model, User $user): Schedule
|
||||||
{
|
{
|
||||||
$assignmentModels = ScheduleAssignmentModel::query()
|
$assignmentModels = ScheduleAssignmentModel::query()
|
||||||
|
|
@ -169,9 +129,6 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
path: $model->element_path,
|
path: $model->element_path,
|
||||||
scheduledDate: $this->date($model->scheduled_date),
|
scheduledDate: $this->date($model->scheduled_date),
|
||||||
position: $model->position,
|
position: $model->position,
|
||||||
completedAt: $model->completed_at === null
|
|
||||||
? null
|
|
||||||
: $this->dateTime($model->completed_at),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,10 +136,4 @@ class EloquentScheduleRepository implements ScheduleRepository
|
||||||
{
|
{
|
||||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dateTime(DateTimeImmutable $value): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return DateTimeImmutable::createFromInterface($value)
|
|
||||||
->setTimezone(new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace App\Schedule;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final class ScheduleAssignment
|
final readonly class ScheduleAssignment
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<string> $path
|
* @param list<string> $path
|
||||||
|
|
@ -16,7 +16,6 @@ final class ScheduleAssignment
|
||||||
private array $path,
|
private array $path,
|
||||||
private DateTimeImmutable $scheduledDate,
|
private DateTimeImmutable $scheduledDate,
|
||||||
private int $position,
|
private int $position,
|
||||||
private ?DateTimeImmutable $completedAt,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
|
@ -51,21 +50,4 @@ final class ScheduleAssignment
|
||||||
{
|
{
|
||||||
return $this->position;
|
return $this->position;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCompletedAt(): ?DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->completedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function complete(DateTimeImmutable $completedAt): void
|
|
||||||
{
|
|
||||||
if ($this->completedAt === null) {
|
|
||||||
$this->completedAt = $completedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reopen(): void
|
|
||||||
{
|
|
||||||
$this->completedAt = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Schedule;
|
namespace App\Schedule;
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -16,7 +15,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
* @property list<string> $element_path
|
* @property list<string> $element_path
|
||||||
* @property string $scheduled_date
|
* @property string $scheduled_date
|
||||||
* @property int $position
|
* @property int $position
|
||||||
* @property DateTimeImmutable|null $completed_at
|
|
||||||
* @property-read ScheduleModel $schedule
|
* @property-read ScheduleModel $schedule
|
||||||
*
|
*
|
||||||
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
||||||
|
|
@ -32,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
'element_path',
|
'element_path',
|
||||||
'scheduled_date',
|
'scheduled_date',
|
||||||
'position',
|
'position',
|
||||||
'completed_at',
|
|
||||||
])]
|
])]
|
||||||
class ScheduleAssignmentModel extends Model
|
class ScheduleAssignmentModel extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -49,7 +46,6 @@ class ScheduleAssignmentModel extends Model
|
||||||
'schedule_id' => 'integer',
|
'schedule_id' => 'integer',
|
||||||
'element_path' => 'array',
|
'element_path' => 'array',
|
||||||
'position' => 'integer',
|
'position' => 'integer',
|
||||||
'completed_at' => 'immutable_datetime',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,4 @@ interface ScheduleRepository
|
||||||
User $user,
|
User $user,
|
||||||
DateTimeImmutable $date,
|
DateTimeImmutable $date,
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
public function findAssignmentForUser(
|
|
||||||
int $id,
|
|
||||||
User $user,
|
|
||||||
): ?ScheduleAssignment;
|
|
||||||
|
|
||||||
public function updateAssignment(
|
|
||||||
ScheduleAssignment $assignment,
|
|
||||||
): ScheduleAssignment;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\SetAssignmentCompletion;
|
|
||||||
|
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\ScheduleAssignment;
|
|
||||||
use App\Schedule\ScheduleRepository;
|
|
||||||
|
|
||||||
class SetAssignmentCompletion
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private ScheduleRepository $scheduleRepository,
|
|
||||||
private Clock $clock,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function execute(
|
|
||||||
SetAssignmentCompletionRequest $request,
|
|
||||||
): ScheduleAssignment {
|
|
||||||
if ($request->completed === null) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'completed must be a boolean',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assignment = $this->scheduleRepository->findAssignmentForUser(
|
|
||||||
$request->assignmentId,
|
|
||||||
$request->user,
|
|
||||||
);
|
|
||||||
if ($assignment === null) {
|
|
||||||
throw new NotFoundException('assignment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->completed) {
|
|
||||||
$assignment->complete($this->clock->now());
|
|
||||||
} else {
|
|
||||||
$assignment->reopen();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->scheduleRepository->updateAssignment($assignment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Schedule\UseCases\SetAssignmentCompletion;
|
|
||||||
|
|
||||||
use App\User\User;
|
|
||||||
|
|
||||||
final readonly class SetAssignmentCompletionRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $assignmentId,
|
|
||||||
public User $user,
|
|
||||||
public ?bool $completed,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -27,11 +27,4 @@ class RequestInput
|
||||||
|
|
||||||
return is_int($value) ? $value : null;
|
return is_int($value) ? $value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boolean(string $key): ?bool
|
|
||||||
{
|
|
||||||
$value = $this->request->input($key);
|
|
||||||
|
|
||||||
return is_bool($value) ? $value : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,10 @@ return new class extends Migration
|
||||||
$table->json('element_path');
|
$table->json('element_path');
|
||||||
$table->date('scheduled_date');
|
$table->date('scheduled_date');
|
||||||
$table->unsignedInteger('position');
|
$table->unsignedInteger('position');
|
||||||
$table->timestamp('completed_at')->nullable();
|
|
||||||
$table->unique(['schedule_id', 'position']);
|
$table->unique(['schedule_id', 'position']);
|
||||||
$table->index([
|
$table->index([
|
||||||
'schedule_id',
|
'schedule_id',
|
||||||
'scheduled_date',
|
'scheduled_date',
|
||||||
'completed_at',
|
|
||||||
'position',
|
'position',
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||||
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
||||||
->whereNumber('setId');
|
->whereNumber('setId');
|
||||||
Route::get('/assignments', [ScheduleController::class, 'assignments']);
|
Route::get('/assignments', [ScheduleController::class, 'assignments']);
|
||||||
Route::patch(
|
|
||||||
'/assignments/{assignmentId}',
|
|
||||||
[ScheduleController::class, 'updateAssignment'],
|
|
||||||
)->whereNumber('assignmentId');
|
|
||||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||||
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ use App\Schedule\ScheduleAssignment;
|
||||||
use App\Schedule\ScheduleRepository;
|
use App\Schedule\ScheduleRepository;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DomainException;
|
|
||||||
|
|
||||||
class FakeScheduleRepository implements ScheduleRepository
|
class FakeScheduleRepository implements ScheduleRepository
|
||||||
{
|
{
|
||||||
|
|
@ -18,8 +17,6 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
*/
|
*/
|
||||||
private array $schedules = [];
|
private array $schedules = [];
|
||||||
|
|
||||||
private int $nextAssignmentId = 1;
|
|
||||||
|
|
||||||
public function create(CreateScheduleDto $dto): Schedule
|
public function create(CreateScheduleDto $dto): Schedule
|
||||||
{
|
{
|
||||||
$id = count($this->schedules) + 1;
|
$id = count($this->schedules) + 1;
|
||||||
|
|
@ -27,13 +24,12 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
|
|
||||||
foreach ($dto->assignments as $assignmentDto) {
|
foreach ($dto->assignments as $assignmentDto) {
|
||||||
$assignments[] = new ScheduleAssignment(
|
$assignments[] = new ScheduleAssignment(
|
||||||
id: $this->nextAssignmentId++,
|
id: count($assignments) + 1,
|
||||||
name: $assignmentDto->name,
|
name: $assignmentDto->name,
|
||||||
kind: $assignmentDto->kind,
|
kind: $assignmentDto->kind,
|
||||||
path: $assignmentDto->path,
|
path: $assignmentDto->path,
|
||||||
scheduledDate: $assignmentDto->scheduledDate,
|
scheduledDate: $assignmentDto->scheduledDate,
|
||||||
position: $assignmentDto->position,
|
position: $assignmentDto->position,
|
||||||
completedAt: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +84,6 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
foreach ($schedule->getAssignments() as $assignment) {
|
foreach ($schedule->getAssignments() as $assignment) {
|
||||||
if ($assignment->getScheduledDate()->format('Y-m-d')
|
if ($assignment->getScheduledDate()->format('Y-m-d')
|
||||||
!== $date->format('Y-m-d')
|
!== $date->format('Y-m-d')
|
||||||
|| $assignment->getCompletedAt() !== null
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -104,66 +99,21 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
return $assignments;
|
return $assignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findAssignmentForUser(
|
|
||||||
int $id,
|
|
||||||
User $user,
|
|
||||||
): ?ScheduleAssignment {
|
|
||||||
foreach ($this->findAllForUser($user) as $schedule) {
|
|
||||||
foreach ($schedule->getAssignments() as $assignment) {
|
|
||||||
if ($assignment->getId() === $id) {
|
|
||||||
return $this->copyAssignment($assignment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateAssignment(
|
|
||||||
ScheduleAssignment $assignment,
|
|
||||||
): ScheduleAssignment {
|
|
||||||
foreach ($this->schedules as $id => $schedule) {
|
|
||||||
$assignments = [];
|
|
||||||
$found = false;
|
|
||||||
|
|
||||||
foreach ($schedule->getAssignments() as $storedAssignment) {
|
|
||||||
if ($storedAssignment->getId() === $assignment->getId()) {
|
|
||||||
$assignments[] = $this->copyAssignment($assignment);
|
|
||||||
$found = true;
|
|
||||||
} else {
|
|
||||||
$assignments[] = $this->copyAssignment(
|
|
||||||
$storedAssignment,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($found) {
|
|
||||||
$this->schedules[$id] = new Schedule(
|
|
||||||
id: $schedule->getId(),
|
|
||||||
user: $schedule->getUser(),
|
|
||||||
setName: $schedule->getSetName(),
|
|
||||||
elementKind: $schedule->getElementKind(),
|
|
||||||
startDate: $schedule->getStartDate(),
|
|
||||||
targetDate: $schedule->getTargetDate(),
|
|
||||||
assignments: $assignments,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->copyAssignment($assignment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new DomainException(
|
|
||||||
"Assignment with id {$assignment->getId()} not found",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copy(Schedule $schedule): Schedule
|
private function copy(Schedule $schedule): Schedule
|
||||||
{
|
{
|
||||||
$assignments = array_map(function (
|
$assignments = array_map(
|
||||||
ScheduleAssignment $assignment,
|
function (ScheduleAssignment $assignment): ScheduleAssignment {
|
||||||
): ScheduleAssignment {
|
return new ScheduleAssignment(
|
||||||
return $this->copyAssignment($assignment);
|
id: $assignment->getId(),
|
||||||
}, $schedule->getAssignments());
|
name: $assignment->getName(),
|
||||||
|
kind: $assignment->getKind(),
|
||||||
|
path: $assignment->getPath(),
|
||||||
|
scheduledDate: $assignment->getScheduledDate(),
|
||||||
|
position: $assignment->getPosition(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
$schedule->getAssignments(),
|
||||||
|
);
|
||||||
|
|
||||||
return new Schedule(
|
return new Schedule(
|
||||||
id: $schedule->getId(),
|
id: $schedule->getId(),
|
||||||
|
|
@ -175,18 +125,4 @@ class FakeScheduleRepository implements ScheduleRepository
|
||||||
assignments: $assignments,
|
assignments: $assignments,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function copyAssignment(
|
|
||||||
ScheduleAssignment $assignment,
|
|
||||||
): ScheduleAssignment {
|
|
||||||
return new ScheduleAssignment(
|
|
||||||
id: $assignment->getId(),
|
|
||||||
name: $assignment->getName(),
|
|
||||||
kind: $assignment->getKind(),
|
|
||||||
path: $assignment->getPath(),
|
|
||||||
scheduledDate: $assignment->getScheduledDate(),
|
|
||||||
position: $assignment->getPosition(),
|
|
||||||
completedAt: $assignment->getCompletedAt(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace Tests\Feature\Schedule;
|
namespace Tests\Feature\Schedule;
|
||||||
|
|
||||||
use App\Auth\CreateSessionDto;
|
use App\Auth\CreateSessionDto;
|
||||||
use App\Auth\Clock;
|
|
||||||
use App\Auth\SessionRepository;
|
use App\Auth\SessionRepository;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Element\ElementModel;
|
use App\Element\ElementModel;
|
||||||
|
|
@ -26,7 +25,6 @@ use DateTimeZone;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Testing\TestResponse;
|
use Illuminate\Testing\TestResponse;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
use Tests\Fakes\FakeClock;
|
|
||||||
|
|
||||||
class ScheduleEndpointTest extends TestCase
|
class ScheduleEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -96,7 +94,6 @@ class ScheduleEndpointTest extends TestCase
|
||||||
'assignments' => [
|
'assignments' => [
|
||||||
[
|
[
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
'completedAt' => null,
|
|
||||||
'element' => [
|
'element' => [
|
||||||
'name' => 'Chapter 1',
|
'name' => 'Chapter 1',
|
||||||
'kind' => 'chapter',
|
'kind' => 'chapter',
|
||||||
|
|
@ -118,7 +115,6 @@ class ScheduleEndpointTest extends TestCase
|
||||||
'assignments' => [
|
'assignments' => [
|
||||||
[
|
[
|
||||||
'id' => 2,
|
'id' => 2,
|
||||||
'completedAt' => null,
|
|
||||||
'element' => [
|
'element' => [
|
||||||
'name' => 'Chapter 1',
|
'name' => 'Chapter 1',
|
||||||
'kind' => 'chapter',
|
'kind' => 'chapter',
|
||||||
|
|
@ -355,130 +351,6 @@ class ScheduleEndpointTest extends TestCase
|
||||||
->assertExactJson(['error' => 'date is required']);
|
->assertExactJson(['error' => 'date is required']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_completes_and_reopens_an_assignment(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$set = $this->createSet($user, 'Course');
|
|
||||||
$lessonLevel = $this->createLevel($set, 'lesson');
|
|
||||||
$repository = app(ElementRepository::class);
|
|
||||||
$repository->create(new CreateElementDto(
|
|
||||||
name: 'First lesson',
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$repository->create(new CreateElementDto(
|
|
||||||
name: 'Second lesson',
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
$completedAt = new DateTimeImmutable(
|
|
||||||
'2026-08-15T12:30:45',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
$this->app->instance(Clock::class, new FakeClock($completedAt));
|
|
||||||
$this->credentialedPost('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-08-20',
|
|
||||||
'targetDate' => '2026-08-20',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
$this->credentialedPatch('/api/assignments/1', [
|
|
||||||
'completed' => true,
|
|
||||||
])->assertOk()->assertExactJson([
|
|
||||||
'assignment' => [
|
|
||||||
'id' => 1,
|
|
||||||
'completedAt' => '2026-08-15T12:30:45+00:00',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('schedule_assignments', [
|
|
||||||
'id' => 1,
|
|
||||||
'completed_at' => '2026-08-15 12:30:45',
|
|
||||||
]);
|
|
||||||
$this->credentialedGet('/api/schedules/1')
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonPath(
|
|
||||||
'schedule.days.0.assignments.0.completedAt',
|
|
||||||
'2026-08-15T12:30:45+00:00',
|
|
||||||
)
|
|
||||||
->assertJsonPath(
|
|
||||||
'schedule.days.0.assignments.1.completedAt',
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
$this->credentialedGet('/api/assignments?date=2026-08-20')
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonCount(1, 'assignments')
|
|
||||||
->assertJsonPath('assignments.0.id', 2);
|
|
||||||
|
|
||||||
$this->credentialedPatch('/api/assignments/1', [
|
|
||||||
'completed' => false,
|
|
||||||
])->assertOk()->assertExactJson([
|
|
||||||
'assignment' => [
|
|
||||||
'id' => 1,
|
|
||||||
'completedAt' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('schedule_assignments', [
|
|
||||||
'id' => 1,
|
|
||||||
'completed_at' => null,
|
|
||||||
]);
|
|
||||||
$this->credentialedGet('/api/assignments?date=2026-08-20')
|
|
||||||
->assertOk()
|
|
||||||
->assertJsonCount(2, 'assignments');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_rejects_invalid_assignment_completion_input(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('reader@example.com');
|
|
||||||
$this->createSession($user, 'valid-token');
|
|
||||||
|
|
||||||
$this->credentialedPatch('/api/assignments/1', [
|
|
||||||
'completed' => 'yes',
|
|
||||||
])->assertBadRequest()->assertExactJson([
|
|
||||||
'error' => 'completed must be a boolean',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_hides_another_users_assignment(): void
|
|
||||||
{
|
|
||||||
$owner = $this->createUser('owner@example.com');
|
|
||||||
$viewer = $this->createUser('viewer@example.com');
|
|
||||||
$set = $this->createSet($owner, 'Course');
|
|
||||||
$lessonLevel = $this->createLevel($set, 'lesson');
|
|
||||||
app(ElementRepository::class)->create(new CreateElementDto(
|
|
||||||
name: 'Welcome',
|
|
||||||
level: $lessonLevel,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
$this->createSession($owner, 'owner-token');
|
|
||||||
$this->createSession($viewer, 'viewer-token');
|
|
||||||
$this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'owner-token',
|
|
||||||
)->postJson('/api/schedules', [
|
|
||||||
'setId' => $set->getId(),
|
|
||||||
'levelId' => $lessonLevel->getId(),
|
|
||||||
'startDate' => '2026-08-15',
|
|
||||||
'targetDate' => '2026-08-15',
|
|
||||||
])->assertCreated();
|
|
||||||
|
|
||||||
$this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'viewer-token',
|
|
||||||
)->patchJson('/api/assignments/1', [
|
|
||||||
'completed' => true,
|
|
||||||
])->assertNotFound()->assertExactJson([
|
|
||||||
'error' => 'assignment not found',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('schedule_assignments', [
|
|
||||||
'id' => 1,
|
|
||||||
'completed_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
public function test_it_is_stable_after_sources_change_or_are_deleted(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('reader@example.com');
|
$user = $this->createUser('reader@example.com');
|
||||||
|
|
@ -583,9 +455,6 @@ class ScheduleEndpointTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->getJson('/api/assignments?date=2026-08-15')
|
$this->getJson('/api/assignments?date=2026-08-15')
|
||||||
->assertStatus(401);
|
->assertStatus(401);
|
||||||
$this->patchJson('/api/assignments/1', [
|
|
||||||
'completed' => true,
|
|
||||||
])->assertStatus(401);
|
|
||||||
$this->getJson('/api/schedules')->assertStatus(401);
|
$this->getJson('/api/schedules')->assertStatus(401);
|
||||||
$this->getJson('/api/schedules/1')->assertStatus(401);
|
$this->getJson('/api/schedules/1')->assertStatus(401);
|
||||||
$this->postJson('/api/schedules', [])->assertStatus(401);
|
$this->postJson('/api/schedules', [])->assertStatus(401);
|
||||||
|
|
@ -651,18 +520,4 @@ class ScheduleEndpointTest extends TestCase
|
||||||
'valid-token',
|
'valid-token',
|
||||||
)->getJson($uri);
|
)->getJson($uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
*/
|
|
||||||
private function credentialedPatch(
|
|
||||||
string $uri,
|
|
||||||
array $payload,
|
|
||||||
): TestResponse {
|
|
||||||
return $this->withCredentials()
|
|
||||||
->withUnencryptedCookie(
|
|
||||||
AuthMiddleware::COOKIE_NAME,
|
|
||||||
'valid-token',
|
|
||||||
)->patchJson($uri, $payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\Schedule;
|
|
||||||
|
|
||||||
use App\Schedule\ScheduleAssignment;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class ScheduleAssignmentTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_it_completes_only_once_until_reopened(): void
|
|
||||||
{
|
|
||||||
$assignment = $this->assignment();
|
|
||||||
$firstCompletion = $this->utc('2026-08-15T12:00:00');
|
|
||||||
$laterCompletion = $this->utc('2026-08-15T13:00:00');
|
|
||||||
|
|
||||||
$assignment->complete($firstCompletion);
|
|
||||||
$assignment->complete($laterCompletion);
|
|
||||||
|
|
||||||
$this->assertSame($firstCompletion, $assignment->getCompletedAt());
|
|
||||||
|
|
||||||
$assignment->reopen();
|
|
||||||
|
|
||||||
$this->assertNull($assignment->getCompletedAt());
|
|
||||||
|
|
||||||
$assignment->complete($laterCompletion);
|
|
||||||
|
|
||||||
$this->assertSame($laterCompletion, $assignment->getCompletedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assignment(): ScheduleAssignment
|
|
||||||
{
|
|
||||||
return new ScheduleAssignment(
|
|
||||||
id: 1,
|
|
||||||
name: 'Lesson',
|
|
||||||
kind: 'lesson',
|
|
||||||
path: ['Course', 'Lesson'],
|
|
||||||
scheduledDate: $this->utc('2026-08-15'),
|
|
||||||
position: 1,
|
|
||||||
completedAt: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function utc(string $value): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,7 @@ class ListAssignmentsForDateTest extends TestCase
|
||||||
$user = $this->user(1, 'reader@example.com');
|
$user = $this->user(1, 'reader@example.com');
|
||||||
$otherUser = $this->user(2, 'other@example.com');
|
$otherUser = $this->user(2, 'other@example.com');
|
||||||
$repository = new FakeScheduleRepository;
|
$repository = new FakeScheduleRepository;
|
||||||
$olderSchedule = $repository->create($this->schedule(
|
$repository->create($this->schedule(
|
||||||
user: $user,
|
user: $user,
|
||||||
setName: 'Older plan',
|
setName: 'Older plan',
|
||||||
date: '2026-08-15',
|
date: '2026-08-15',
|
||||||
|
|
@ -46,12 +46,6 @@ class ListAssignmentsForDateTest extends TestCase
|
||||||
date: '2026-08-16',
|
date: '2026-08-16',
|
||||||
assignmentNames: ['Later'],
|
assignmentNames: ['Later'],
|
||||||
));
|
));
|
||||||
$completedAssignment = $olderSchedule->getAssignments()[0];
|
|
||||||
$completedAssignment->complete(new DateTimeImmutable(
|
|
||||||
'2026-08-15T12:00:00',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
));
|
|
||||||
$repository->updateAssignment($completedAssignment);
|
|
||||||
|
|
||||||
$assignments = (new ListAssignmentsForDate($repository))->execute(
|
$assignments = (new ListAssignmentsForDate($repository))->execute(
|
||||||
new ListAssignmentsForDateRequest(
|
new ListAssignmentsForDateRequest(
|
||||||
|
|
@ -61,13 +55,13 @@ class ListAssignmentsForDateTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['Newer plan', 'Older plan'],
|
['Newer plan', 'Older plan', 'Older plan'],
|
||||||
array_map(function ($assignment): string {
|
array_map(function ($assignment): string {
|
||||||
return $assignment->getSetName();
|
return $assignment->getSetName();
|
||||||
}, $assignments),
|
}, $assignments),
|
||||||
);
|
);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['Third', 'Second'],
|
['Third', 'First', 'Second'],
|
||||||
array_map(function ($assignment): string {
|
array_map(function ($assignment): string {
|
||||||
return $assignment->getAssignment()->getName();
|
return $assignment->getAssignment()->getName();
|
||||||
}, $assignments),
|
}, $assignments),
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\Schedule\UseCases;
|
|
||||||
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Schedule\CreateScheduleAssignmentDto;
|
|
||||||
use App\Schedule\CreateScheduleDto;
|
|
||||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
|
|
||||||
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
use App\User\User;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Tests\Fakes\FakeClock;
|
|
||||||
use Tests\Fakes\FakeScheduleRepository;
|
|
||||||
|
|
||||||
class SetAssignmentCompletionTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_it_completes_an_owned_assignment_at_the_current_time(): void
|
|
||||||
{
|
|
||||||
$user = $this->user(1, 'reader@example.com');
|
|
||||||
$repository = new FakeScheduleRepository;
|
|
||||||
$schedule = $repository->create($this->schedule($user));
|
|
||||||
$now = $this->utc('2026-08-15T12:00:00');
|
|
||||||
|
|
||||||
$assignment = (new SetAssignmentCompletion(
|
|
||||||
$repository,
|
|
||||||
new FakeClock($now),
|
|
||||||
))->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $schedule->getAssignments()[0]->getId(),
|
|
||||||
user: $user,
|
|
||||||
completed: true,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame($now, $assignment->getCompletedAt());
|
|
||||||
$this->assertSame(
|
|
||||||
$now,
|
|
||||||
$repository->findAssignmentForUser(
|
|
||||||
$assignment->getId(),
|
|
||||||
$user,
|
|
||||||
)?->getCompletedAt(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_preserves_the_first_completion_time(): void
|
|
||||||
{
|
|
||||||
$user = $this->user(1, 'reader@example.com');
|
|
||||||
$repository = new FakeScheduleRepository;
|
|
||||||
$schedule = $repository->create($this->schedule($user));
|
|
||||||
$assignmentId = $schedule->getAssignments()[0]->getId();
|
|
||||||
$firstCompletion = $this->utc('2026-08-15T12:00:00');
|
|
||||||
$laterCompletion = $this->utc('2026-08-15T13:00:00');
|
|
||||||
|
|
||||||
(new SetAssignmentCompletion(
|
|
||||||
$repository,
|
|
||||||
new FakeClock($firstCompletion),
|
|
||||||
))->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $assignmentId,
|
|
||||||
user: $user,
|
|
||||||
completed: true,
|
|
||||||
));
|
|
||||||
$assignment = (new SetAssignmentCompletion(
|
|
||||||
$repository,
|
|
||||||
new FakeClock($laterCompletion),
|
|
||||||
))->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $assignmentId,
|
|
||||||
user: $user,
|
|
||||||
completed: true,
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
$firstCompletion,
|
|
||||||
$assignment->getCompletedAt(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_reopens_a_completed_assignment(): void
|
|
||||||
{
|
|
||||||
$user = $this->user(1, 'reader@example.com');
|
|
||||||
$repository = new FakeScheduleRepository;
|
|
||||||
$schedule = $repository->create($this->schedule($user));
|
|
||||||
$assignmentId = $schedule->getAssignments()[0]->getId();
|
|
||||||
$useCase = new SetAssignmentCompletion(
|
|
||||||
$repository,
|
|
||||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
|
||||||
);
|
|
||||||
$useCase->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $assignmentId,
|
|
||||||
user: $user,
|
|
||||||
completed: true,
|
|
||||||
));
|
|
||||||
|
|
||||||
$assignment = $useCase->execute(
|
|
||||||
new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $assignmentId,
|
|
||||||
user: $user,
|
|
||||||
completed: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertNull($assignment->getCompletedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_hides_another_users_assignment(): void
|
|
||||||
{
|
|
||||||
$repository = new FakeScheduleRepository;
|
|
||||||
$owner = $this->user(1, 'owner@example.com');
|
|
||||||
$schedule = $repository->create($this->schedule($owner));
|
|
||||||
|
|
||||||
$this->expectException(NotFoundException::class);
|
|
||||||
$this->expectExceptionMessage('assignment not found');
|
|
||||||
|
|
||||||
(new SetAssignmentCompletion(
|
|
||||||
$repository,
|
|
||||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
|
||||||
))->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: $schedule->getAssignments()[0]->getId(),
|
|
||||||
user: $this->user(2, 'viewer@example.com'),
|
|
||||||
completed: true,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_requires_a_boolean_completion_value(): void
|
|
||||||
{
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('completed must be a boolean');
|
|
||||||
|
|
||||||
(new SetAssignmentCompletion(
|
|
||||||
new FakeScheduleRepository,
|
|
||||||
new FakeClock($this->utc('2026-08-15T12:00:00')),
|
|
||||||
))->execute(new SetAssignmentCompletionRequest(
|
|
||||||
assignmentId: 1,
|
|
||||||
user: $this->user(1, 'reader@example.com'),
|
|
||||||
completed: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function schedule(User $user): CreateScheduleDto
|
|
||||||
{
|
|
||||||
$scheduledDate = $this->utc('2026-08-20');
|
|
||||||
|
|
||||||
return new CreateScheduleDto(
|
|
||||||
user: $user,
|
|
||||||
setName: 'Course',
|
|
||||||
elementKind: 'lesson',
|
|
||||||
startDate: $scheduledDate,
|
|
||||||
targetDate: $scheduledDate,
|
|
||||||
assignments: [
|
|
||||||
new CreateScheduleAssignmentDto(
|
|
||||||
name: 'Lesson',
|
|
||||||
kind: 'lesson',
|
|
||||||
path: ['Course', 'Lesson'],
|
|
||||||
scheduledDate: $scheduledDate,
|
|
||||||
position: 1,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function user(int $id, string $email): User
|
|
||||||
{
|
|
||||||
return new User(
|
|
||||||
id: $id,
|
|
||||||
email: new EmailAddress($email),
|
|
||||||
passwordHash: 'hashed-password',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function utc(string $value): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -74,7 +74,6 @@ const scheduleDetail = {
|
||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
completedAt: null,
|
|
||||||
element: {
|
element: {
|
||||||
name: 'Chapter 1',
|
name: 'Chapter 1',
|
||||||
kind: 'Chapter_sections-v2',
|
kind: 'Chapter_sections-v2',
|
||||||
|
|
@ -89,7 +88,6 @@ const scheduleDetail = {
|
||||||
assignments: [
|
assignments: [
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
completedAt: null,
|
|
||||||
element: {
|
element: {
|
||||||
name: 'Chapter 1',
|
name: 'Chapter 1',
|
||||||
kind: 'Chapter_sections-v2',
|
kind: 'Chapter_sections-v2',
|
||||||
|
|
@ -102,25 +100,6 @@ const scheduleDetail = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const completedScheduleDetail = {
|
|
||||||
schedule: {
|
|
||||||
...scheduleDetail.schedule,
|
|
||||||
days: [
|
|
||||||
scheduleDetail.schedule.days[0],
|
|
||||||
scheduleDetail.schedule.days[1],
|
|
||||||
{
|
|
||||||
...scheduleDetail.schedule.days[2],
|
|
||||||
assignments: [
|
|
||||||
{
|
|
||||||
...scheduleDetail.schedule.days[2]?.assignments[0],
|
|
||||||
completedAt: '2026-08-15T09:30:00+00:00',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function interceptAuthenticatedUser(): void {
|
function interceptAuthenticatedUser(): void {
|
||||||
cy.intercept('GET', '**/api/me', {
|
cy.intercept('GET', '**/api/me', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
@ -248,65 +227,6 @@ describe('set scheduling', () => {
|
||||||
cy.get('h1').should('have.text', 'Schedule not found')
|
cy.get('h1').should('have.text', 'Schedule not found')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('completes and reopens assignments from the schedule', () => {
|
|
||||||
const completedAt = '2026-08-15T12:30:00+00:00'
|
|
||||||
cy.intercept('GET', '**/api/schedules/73', {
|
|
||||||
statusCode: 200,
|
|
||||||
body: completedScheduleDetail,
|
|
||||||
}).as('schedule')
|
|
||||||
cy.intercept('PATCH', '**/api/assignments/1', (request) => {
|
|
||||||
expect(request.body).to.deep.equal({ completed: true })
|
|
||||||
request.reply({
|
|
||||||
delay: 400,
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
assignment: { id: 1, completedAt },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).as('completeAssignment')
|
|
||||||
cy.intercept('PATCH', '**/api/assignments/2', (request) => {
|
|
||||||
expect(request.body).to.deep.equal({ completed: false })
|
|
||||||
request.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
assignment: { id: 2, completedAt: null },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}).as('reopenAssignment')
|
|
||||||
|
|
||||||
cy.visit('/schedules/73')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@schedule')
|
|
||||||
|
|
||||||
cy.get('[data-schedule-assignment="2"]').within(() => {
|
|
||||||
cy.contains('Completed').should('be.visible')
|
|
||||||
cy.get('time')
|
|
||||||
.should('have.attr', 'datetime', '2026-08-15T09:30:00+00:00')
|
|
||||||
.and('have.text', formatCompletionTime('2026-08-15T09:30:00+00:00'))
|
|
||||||
cy.contains('button', 'Reopen').click()
|
|
||||||
})
|
|
||||||
cy.wait('@reopenAssignment')
|
|
||||||
cy.get('[data-schedule-assignment="2"]')
|
|
||||||
.should('contain.text', 'Not completed')
|
|
||||||
.and('not.contain.text', 'Completed')
|
|
||||||
.within(() => {
|
|
||||||
cy.contains('button', 'Mark complete').should('be.enabled')
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.get('[data-schedule-assignment="1"]').within(() => {
|
|
||||||
cy.contains('button', 'Mark complete').click()
|
|
||||||
cy.contains('button', 'Saving...').should('be.disabled')
|
|
||||||
})
|
|
||||||
cy.wait('@completeAssignment')
|
|
||||||
cy.get('[data-schedule-assignment="1"]').within(() => {
|
|
||||||
cy.contains('Completed').should('be.visible')
|
|
||||||
cy.get('time')
|
|
||||||
.should('have.attr', 'datetime', completedAt)
|
|
||||||
.and('have.text', formatCompletionTime(completedAt))
|
|
||||||
cy.contains('button', 'Reopen').should('be.enabled')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lists the users schedules on the dashboard', () => {
|
it('lists the users schedules on the dashboard', () => {
|
||||||
cy.intercept('GET', '**/api/sets', {
|
cy.intercept('GET', '**/api/sets', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
@ -352,10 +272,3 @@ describe('set scheduling', () => {
|
||||||
cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73')
|
cy.contains('a', 'Bible').should('have.attr', 'href', '/schedules/73')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatCompletionTime(value: string): string {
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(value))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -113,74 +113,6 @@ describe("today's assignments", () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('completes an assignment and keeps failures retryable', () => {
|
|
||||||
cy.intercept('GET', `**/api/assignments?date=${browserToday}`, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
date: browserToday,
|
|
||||||
assignments: [
|
|
||||||
{
|
|
||||||
id: 12,
|
|
||||||
schedule: {
|
|
||||||
id: 73,
|
|
||||||
set: { name: 'Bible' },
|
|
||||||
},
|
|
||||||
element: {
|
|
||||||
name: 'Chapter 1',
|
|
||||||
kind: 'chapter',
|
|
||||||
path: ['Genesis', 'Chapter 1'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}).as('todayAssignments')
|
|
||||||
let requestCount = 0
|
|
||||||
cy.intercept('PATCH', '**/api/assignments/12', (request) => {
|
|
||||||
requestCount += 1
|
|
||||||
request.alias = `completion${requestCount}`
|
|
||||||
expect(request.body).to.deep.equal({ completed: true })
|
|
||||||
|
|
||||||
if (requestCount === 1) {
|
|
||||||
request.reply({ delay: 400, statusCode: 500 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
request.reply({
|
|
||||||
delay: 400,
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
assignment: {
|
|
||||||
id: 12,
|
|
||||||
completedAt: '2026-08-15T12:30:00+00:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.visit('/dashboard')
|
|
||||||
cy.wait('@me')
|
|
||||||
cy.wait('@todayAssignments')
|
|
||||||
|
|
||||||
cy.get('[data-today-assignment="12"]').within(() => {
|
|
||||||
cy.contains('button', 'Mark complete').click()
|
|
||||||
cy.contains('button', 'Completing...').should('be.disabled')
|
|
||||||
})
|
|
||||||
cy.wait('@completion1')
|
|
||||||
cy.get('[data-today-assignment="12"]')
|
|
||||||
.should('contain.text', "We couldn't update this assignment.")
|
|
||||||
.within(() => {
|
|
||||||
cy.contains('button', 'Mark complete').click()
|
|
||||||
cy.contains('button', 'Completing...').should('be.disabled')
|
|
||||||
})
|
|
||||||
cy.wait('@completion2')
|
|
||||||
|
|
||||||
cy.get('[data-today-assignment="12"]').should('not.exist')
|
|
||||||
cy.get('.today-assignments [role="status"]').should(
|
|
||||||
'contain.text',
|
|
||||||
'Nothing is assigned for today.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries independently after the request fails', () => {
|
it('retries independently after the request fails', () => {
|
||||||
let requestCount = 0
|
let requestCount = 0
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({
|
||||||
assignmentCount: z.number().int().nonnegative(),
|
assignmentCount: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignmentIdentitySchema = z.object({
|
const scheduleAssignmentSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
element: z.object({
|
element: z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -26,11 +26,7 @@ const assignmentIdentitySchema = z.object({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const scheduleAssignmentSchema = assignmentIdentitySchema.extend({
|
export const assignmentForDateSchema = scheduleAssignmentSchema.extend({
|
||||||
completedAt: z.string().datetime({ offset: true }).nullable(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const assignmentForDateSchema = assignmentIdentitySchema.extend({
|
|
||||||
schedule: z.object({
|
schedule: z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
set: z.object({
|
set: z.object({
|
||||||
|
|
@ -61,13 +57,6 @@ const assignmentsForDateResponseSchema = z.object({
|
||||||
assignments: z.array(assignmentForDateSchema),
|
assignments: z.array(assignmentForDateSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignmentCompletionResponseSchema = z.object({
|
|
||||||
assignment: z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
completedAt: z.string().datetime({ offset: true }).nullable(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorResponseSchema = z.object({
|
const errorResponseSchema = z.object({
|
||||||
error: z.string().min(1),
|
error: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
@ -86,7 +75,6 @@ const LIST_ERROR = "We couldn't load your schedules."
|
||||||
const DETAIL_ERROR = "We couldn't load this schedule."
|
const DETAIL_ERROR = "We couldn't load this schedule."
|
||||||
const CREATE_ERROR = "We couldn't create this schedule."
|
const CREATE_ERROR = "We couldn't create this schedule."
|
||||||
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
|
const ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
|
||||||
const COMPLETION_ERROR = "We couldn't update this assignment."
|
|
||||||
|
|
||||||
export const useSchedulesStore = defineStore('schedules', () => {
|
export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
const schedules = ref<ScheduleSummary[]>([])
|
const schedules = ref<ScheduleSummary[]>([])
|
||||||
|
|
@ -101,8 +89,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
const assignmentsForDate = ref<AssignmentForDate[]>([])
|
||||||
const assignmentsLoading = ref(false)
|
const assignmentsLoading = ref(false)
|
||||||
const assignmentsError = ref<string | null>(null)
|
const assignmentsError = ref<string | null>(null)
|
||||||
const assignmentCompletionPendingIds = ref<number[]>([])
|
|
||||||
const assignmentCompletionErrors = ref<Record<number, string>>({})
|
|
||||||
let activeDetailRequestId = 0
|
let activeDetailRequestId = 0
|
||||||
let assignmentsRequestId = 0
|
let assignmentsRequestId = 0
|
||||||
|
|
||||||
|
|
@ -288,84 +274,6 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAssignmentCompleted(
|
|
||||||
assignmentId: number,
|
|
||||||
completed: boolean,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (assignmentCompletionPendingIds.value.includes(assignmentId)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
assignmentCompletionPendingIds.value = [...assignmentCompletionPendingIds.value, assignmentId]
|
|
||||||
const remainingErrors = { ...assignmentCompletionErrors.value }
|
|
||||||
delete remainingErrors[assignmentId]
|
|
||||||
assignmentCompletionErrors.value = remainingErrors
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/assignments/${assignmentId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ completed }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error('assignment completion request failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody: unknown = await response.json()
|
|
||||||
const assignment = assignmentCompletionResponseSchema.parse(responseBody).assignment
|
|
||||||
const completionDoesNotMatchRequest = completed
|
|
||||||
? assignment.completedAt === null
|
|
||||||
: assignment.completedAt !== null
|
|
||||||
if (assignment.id !== assignmentId || completionDoesNotMatchRequest) {
|
|
||||||
throw new Error('assignment completion response did not match request')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSchedule.value !== null) {
|
|
||||||
for (const day of activeSchedule.value.days) {
|
|
||||||
const activeAssignment = day.assignments.find(
|
|
||||||
(candidate) => candidate.id === assignmentId,
|
|
||||||
)
|
|
||||||
if (activeAssignment !== undefined) {
|
|
||||||
activeAssignment.completedAt = assignment.completedAt
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assignment.completedAt !== null) {
|
|
||||||
assignmentsForDate.value = assignmentsForDate.value.filter(
|
|
||||||
(candidate) => candidate.id !== assignmentId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
assignmentCompletionErrors.value = {
|
|
||||||
...assignmentCompletionErrors.value,
|
|
||||||
[assignmentId]: COMPLETION_ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
assignmentCompletionPendingIds.value = assignmentCompletionPendingIds.value.filter(
|
|
||||||
(pendingId) => pendingId !== assignmentId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAssignmentCompletionPending(assignmentId: number): boolean {
|
|
||||||
return assignmentCompletionPendingIds.value.includes(assignmentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignmentCompletionError(assignmentId: number): string | null {
|
|
||||||
return assignmentCompletionErrors.value[assignmentId] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schedules,
|
schedules,
|
||||||
listLoading,
|
listLoading,
|
||||||
|
|
@ -379,14 +287,9 @@ export const useSchedulesStore = defineStore('schedules', () => {
|
||||||
assignmentsForDate,
|
assignmentsForDate,
|
||||||
assignmentsLoading,
|
assignmentsLoading,
|
||||||
assignmentsError,
|
assignmentsError,
|
||||||
assignmentCompletionPendingIds,
|
|
||||||
assignmentCompletionErrors,
|
|
||||||
fetchSchedules,
|
fetchSchedules,
|
||||||
fetchSchedule,
|
fetchSchedule,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
fetchAssignmentsForDate,
|
fetchAssignmentsForDate,
|
||||||
setAssignmentCompleted,
|
|
||||||
isAssignmentCompletionPending,
|
|
||||||
assignmentCompletionError,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,6 @@ function formatDate(value: string): string {
|
||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
}).format(new Date(`${value}T00:00:00Z`))
|
}).format(new Date(`${value}T00:00:00Z`))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeAssignment(assignmentId: number): Promise<void> {
|
|
||||||
await schedulesStore.setAssignmentCompleted(assignmentId, true)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -80,19 +76,15 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
|
<ul v-else class="today-assignment-list" aria-label="Today's assignments">
|
||||||
<li
|
<li v-for="assignment in assignmentsForDate" :key="assignment.id">
|
||||||
v-for="assignment in assignmentsForDate"
|
<RouterLink
|
||||||
:key="assignment.id"
|
class="today-assignment-link"
|
||||||
:data-today-assignment="assignment.id"
|
:to="{
|
||||||
>
|
name: 'schedule-detail',
|
||||||
<article class="today-assignment">
|
params: { scheduleId: assignment.schedule.id },
|
||||||
<RouterLink
|
}"
|
||||||
class="today-assignment-link"
|
>
|
||||||
:to="{
|
<article class="today-assignment">
|
||||||
name: 'schedule-detail',
|
|
||||||
params: { scheduleId: assignment.schedule.id },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="today-assignment__heading">
|
<div class="today-assignment__heading">
|
||||||
<p>{{ assignment.schedule.set.name }}</p>
|
<p>{{ assignment.schedule.set.name }}</p>
|
||||||
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
|
<span class="today-assignment__kind">{{ assignment.element.kind }}</span>
|
||||||
|
|
@ -104,30 +96,8 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
||||||
Open schedule
|
Open schedule
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</article>
|
||||||
<div class="today-assignment__completion">
|
</RouterLink>
|
||||||
<p
|
|
||||||
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
|
|
||||||
class="assignment-completion-error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="assignment-completion-button"
|
|
||||||
:aria-label="`Mark ${assignment.element.path.join(' / ')} complete`"
|
|
||||||
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
|
|
||||||
@click="completeAssignment(assignment.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
schedulesStore.isAssignmentCompletionPending(assignment.id)
|
|
||||||
? 'Completing...'
|
|
||||||
: 'Mark complete'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -343,13 +313,12 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
||||||
|
|
||||||
.today-assignment-link {
|
.today-assignment-link {
|
||||||
display: block;
|
display: block;
|
||||||
color: inherit;
|
height: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-assignment {
|
.today-assignment {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 1.4rem;
|
padding: 1.4rem;
|
||||||
border: 1px solid rgb(24 48 41 / 12%);
|
border: 1px solid rgb(24 48 41 / 12%);
|
||||||
|
|
@ -361,7 +330,7 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
||||||
transform 160ms ease;
|
transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-assignment:hover {
|
.today-assignment-link:hover .today-assignment {
|
||||||
border-color: rgb(40 92 78 / 35%);
|
border-color: rgb(40 92 78 / 35%);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
@ -408,48 +377,6 @@ async function completeAssignment(assignmentId: number): Promise<void> {
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-assignment__completion {
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
gap: 0.65rem;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 1.15rem;
|
|
||||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button {
|
|
||||||
min-height: 2.5rem;
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
border: 1px solid rgb(24 58 49 / 28%);
|
|
||||||
border-radius: 0.7rem;
|
|
||||||
color: #fffdf7;
|
|
||||||
background: #285c4e;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:hover:not(:disabled) {
|
|
||||||
background: #183a31;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 34%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-error {
|
|
||||||
margin: 0;
|
|
||||||
color: #9b3f32;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sets-catalog__description {
|
.sets-catalog__description {
|
||||||
max-width: 38rem;
|
max-width: 38rem;
|
||||||
margin: 1.5rem 0 0;
|
margin: 1.5rem 0 0;
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,6 @@ function formatDate(value: string): string {
|
||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
}).format(new Date(`${value}T00:00:00Z`))
|
}).format(new Date(`${value}T00:00:00Z`))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCompletionTime(value: string): string {
|
|
||||||
return new Intl.DateTimeFormat('en', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
|
|
||||||
await schedulesStore.setAssignmentCompleted(assignmentId, completed)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -109,53 +98,9 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
|
||||||
<p v-if="day.assignments.length === 0" class="rest-day">Rest day</p>
|
<p v-if="day.assignments.length === 0" class="rest-day">Rest day</p>
|
||||||
|
|
||||||
<ul v-else class="assignment-list">
|
<ul v-else class="assignment-list">
|
||||||
<li
|
<li v-for="assignment in day.assignments" :key="assignment.id">
|
||||||
v-for="assignment in day.assignments"
|
<span class="assignment-path">{{ assignment.element.path.join(' / ') }}</span>
|
||||||
:key="assignment.id"
|
<span class="assignment-kind">{{ assignment.element.kind }}</span>
|
||||||
:class="{ 'is-completed': assignment.completedAt !== null }"
|
|
||||||
:data-schedule-assignment="assignment.id"
|
|
||||||
>
|
|
||||||
<div class="assignment-description">
|
|
||||||
<span class="assignment-path">
|
|
||||||
{{ assignment.element.path.join(' / ') }}
|
|
||||||
</span>
|
|
||||||
<span class="assignment-kind">{{ assignment.element.kind }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="assignment-completion">
|
|
||||||
<p v-if="assignment.completedAt !== null" class="assignment-completion__status">
|
|
||||||
Completed
|
|
||||||
<time :datetime="assignment.completedAt">
|
|
||||||
{{ formatCompletionTime(assignment.completedAt) }}
|
|
||||||
</time>
|
|
||||||
</p>
|
|
||||||
<p v-else class="assignment-completion__status">Not completed</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="assignment-completion-button"
|
|
||||||
:aria-label="
|
|
||||||
assignment.completedAt === null
|
|
||||||
? `Mark ${assignment.element.path.join(' / ')} complete`
|
|
||||||
: `Reopen ${assignment.element.path.join(' / ')}`
|
|
||||||
"
|
|
||||||
:disabled="schedulesStore.isAssignmentCompletionPending(assignment.id)"
|
|
||||||
@click="setAssignmentCompleted(assignment.id, assignment.completedAt === null)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
schedulesStore.isAssignmentCompletionPending(assignment.id)
|
|
||||||
? 'Saving...'
|
|
||||||
: assignment.completedAt === null
|
|
||||||
? 'Mark complete'
|
|
||||||
: 'Reopen'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
<p
|
|
||||||
v-if="schedulesStore.assignmentCompletionError(assignment.id) !== null"
|
|
||||||
class="assignment-completion__error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ schedulesStore.assignmentCompletionError(assignment.id) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -278,17 +223,13 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-list li {
|
.assignment-list li {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
align-items: center;
|
||||||
align-items: start;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-list li.is-completed {
|
|
||||||
background: rgb(220 235 224 / 34%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-list li + li {
|
.assignment-list li + li {
|
||||||
border-top: 1px solid rgb(24 48 41 / 10%);
|
border-top: 1px solid rgb(24 48 41 / 10%);
|
||||||
}
|
}
|
||||||
|
|
@ -300,14 +241,6 @@ h1 {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-description {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-kind {
|
.assignment-kind {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0.35rem 0.55rem;
|
padding: 0.35rem 0.55rem;
|
||||||
|
|
@ -320,68 +253,6 @@ h1 {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-completion {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.55rem;
|
|
||||||
min-width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion__status,
|
|
||||||
.assignment-completion__error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion__status {
|
|
||||||
color: #5e7067;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion__status time {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: #344e45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion__error {
|
|
||||||
max-width: 14rem;
|
|
||||||
color: #9b3f32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button {
|
|
||||||
min-height: 2.4rem;
|
|
||||||
padding: 0.55rem 0.8rem;
|
|
||||||
border: 1px solid rgb(24 58 49 / 28%);
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
color: #183a31;
|
|
||||||
background: #fffdf7;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-completed .assignment-completion-button {
|
|
||||||
color: #5e7067;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:hover:not(:disabled) {
|
|
||||||
border-color: #285c4e;
|
|
||||||
background: #f9f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:focus-visible {
|
|
||||||
outline: 3px solid rgb(86 127 112 / 34%);
|
|
||||||
outline-offset: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion-button:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-state {
|
.page-state {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 10rem;
|
min-height: 10rem;
|
||||||
|
|
@ -431,21 +302,7 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-list li {
|
.assignment-list li {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-description {
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-completion {
|
|
||||||
justify-items: start;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.assignment-completion__status,
|
|
||||||
.assignment-completion__error {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue