From 916f070455f0ff61ea5dddafeaf9768e978efb7d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 22:39:34 +0300 Subject: [PATCH 1/5] test assignment completion --- .../Feature/Schedule/ScheduleEndpointTest.php | 145 +++++++++++++++ .../Unit/Schedule/ScheduleAssignmentTest.php | 49 +++++ .../UseCases/ListAssignmentsForDateTest.php | 12 +- .../UseCases/SetAssignmentCompletionTest.php | 175 ++++++++++++++++++ 4 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 backend/tests/Unit/Schedule/ScheduleAssignmentTest.php create mode 100644 backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index a0e9cf8..b68e2ad 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Schedule; use App\Auth\CreateSessionDto; +use App\Auth\Clock; use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementModel; @@ -25,6 +26,7 @@ use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\TestResponse; use Tests\TestCase; +use Tests\Fakes\FakeClock; class ScheduleEndpointTest extends TestCase { @@ -94,6 +96,7 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 1, + 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -115,6 +118,7 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 2, + 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -351,6 +355,130 @@ class ScheduleEndpointTest extends TestCase ->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 { $user = $this->createUser('reader@example.com'); @@ -455,6 +583,9 @@ class ScheduleEndpointTest extends TestCase { $this->getJson('/api/assignments?date=2026-08-15') ->assertStatus(401); + $this->patchJson('/api/assignments/1', [ + 'completed' => true, + ])->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401); @@ -520,4 +651,18 @@ class ScheduleEndpointTest extends TestCase 'valid-token', )->getJson($uri); } + + /** + * @param array $payload + */ + private function credentialedPatch( + string $uri, + array $payload, + ): TestResponse { + return $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->patchJson($uri, $payload); + } } diff --git a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php new file mode 100644 index 0000000..12f86f5 --- /dev/null +++ b/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php @@ -0,0 +1,49 @@ +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')); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php index 1d1e3fd..c6bc95f 100644 --- a/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php +++ b/backend/tests/Unit/Schedule/UseCases/ListAssignmentsForDateTest.php @@ -22,7 +22,7 @@ class ListAssignmentsForDateTest extends TestCase $user = $this->user(1, 'reader@example.com'); $otherUser = $this->user(2, 'other@example.com'); $repository = new FakeScheduleRepository; - $repository->create($this->schedule( + $olderSchedule = $repository->create($this->schedule( user: $user, setName: 'Older plan', date: '2026-08-15', @@ -46,6 +46,12 @@ class ListAssignmentsForDateTest extends TestCase date: '2026-08-16', 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( new ListAssignmentsForDateRequest( @@ -55,13 +61,13 @@ class ListAssignmentsForDateTest extends TestCase ); $this->assertSame( - ['Newer plan', 'Older plan', 'Older plan'], + ['Newer plan', 'Older plan'], array_map(function ($assignment): string { return $assignment->getSetName(); }, $assignments), ); $this->assertSame( - ['Third', 'First', 'Second'], + ['Third', 'Second'], array_map(function ($assignment): string { return $assignment->getAssignment()->getName(); }, $assignments), diff --git a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php new file mode 100644 index 0000000..8950edc --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php @@ -0,0 +1,175 @@ +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')); + } +} From 350d2ec0b7a1111974e938b00db8ece77d9cdf86 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 22:43:06 +0300 Subject: [PATCH 2/5] add assignment completion api --- .../Http/Controllers/ScheduleController.php | 42 ++++++++- .../Schedule/EloquentScheduleRepository.php | 56 +++++++++++ backend/app/Schedule/ScheduleAssignment.php | 20 +++- .../app/Schedule/ScheduleAssignmentModel.php | 4 + backend/app/Schedule/ScheduleRepository.php | 9 ++ .../SetAssignmentCompletion.php | 47 ++++++++++ .../SetAssignmentCompletionRequest.php | 14 +++ backend/app/Shared/Http/RequestInput.php | 7 ++ ...0001_create_schedule_assignments_table.php | 2 + backend/routes/api.php | 4 + .../tests/Fakes/FakeScheduleRepository.php | 92 ++++++++++++++++--- 11 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php create mode 100644 backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index 9ec6588..f80461a 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -14,6 +14,8 @@ use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; use App\Schedule\UseCases\ListSchedules\ListSchedules; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; 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\User\User; use Illuminate\Http\JsonResponse; @@ -26,6 +28,7 @@ class ScheduleController extends Controller private ListSchedules $listSchedules, private GetSchedule $getSchedule, private ListAssignmentsForDate $listAssignmentsForDate, + private SetAssignmentCompletion $setAssignmentCompletion, ) {} public function store(Request $request): JsonResponse @@ -124,6 +127,41 @@ 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 */ @@ -180,7 +218,7 @@ class ScheduleController extends Controller } /** - * @return array{id: int, element: array{ + * @return array{id: int, completedAt: string|null, element: array{ * name: string, * kind: string, * path: list @@ -191,6 +229,8 @@ class ScheduleController extends Controller ): array { return [ 'id' => $assignment->getId(), + 'completedAt' => $assignment->getCompletedAt() + ?->format(DATE_ATOM), 'element' => [ 'name' => $assignment->getName(), 'kind' => $assignment->getKind(), diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 7aec036..8cbde2a 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -5,6 +5,7 @@ namespace App\Schedule; use App\User\User; use DateTimeImmutable; use DateTimeZone; +use DomainException; use Illuminate\Support\Facades\DB; class EloquentScheduleRepository implements ScheduleRepository @@ -29,6 +30,7 @@ class EloquentScheduleRepository implements ScheduleRepository 'scheduled_date' => $assignmentDto->scheduledDate ->format('Y-m-d'), 'position' => $assignmentDto->position, + 'completed_at' => null, ]); } @@ -77,6 +79,7 @@ class EloquentScheduleRepository implements ScheduleRepository ->where('schedule_assignments.scheduled_date', $date->format( 'Y-m-d', )) + ->whereNull('schedule_assignments.completed_at') ->with('schedule') ->orderByDesc('schedules.id') ->orderBy('schedule_assignments.position') @@ -95,6 +98,50 @@ class EloquentScheduleRepository implements ScheduleRepository 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 { + $query = ScheduleAssignmentModel::query() + ->whereKey($assignment->getId()); + if ($assignment->getCompletedAt() === null) { + $query->update(['completed_at' => null]); + } else { + $query->whereNull('completed_at')->update([ + 'completed_at' => $assignment->getCompletedAt(), + ]); + } + + $model = ScheduleAssignmentModel::find($assignment->getId()); + if ($model === null) { + throw new DomainException( + "Assignment with id {$assignment->getId()} not found", + ); + } + + return $this->assignmentToDomain($model); + } + private function toDomain(ScheduleModel $model, User $user): Schedule { $assignmentModels = ScheduleAssignmentModel::query() @@ -129,6 +176,9 @@ class EloquentScheduleRepository implements ScheduleRepository path: $model->element_path, scheduledDate: $this->date($model->scheduled_date), position: $model->position, + completedAt: $model->completed_at === null + ? null + : $this->dateTime($model->completed_at), ); } @@ -136,4 +186,10 @@ class EloquentScheduleRepository implements ScheduleRepository { return new DateTimeImmutable($value, new DateTimeZone('UTC')); } + + private function dateTime(DateTimeImmutable $value): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($value) + ->setTimezone(new DateTimeZone('UTC')); + } } diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php index 482b45d..8f90435 100644 --- a/backend/app/Schedule/ScheduleAssignment.php +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -4,7 +4,7 @@ namespace App\Schedule; use DateTimeImmutable; -final readonly class ScheduleAssignment +final class ScheduleAssignment { /** * @param list $path @@ -16,6 +16,7 @@ final readonly class ScheduleAssignment private array $path, private DateTimeImmutable $scheduledDate, private int $position, + private ?DateTimeImmutable $completedAt, ) {} public function getId(): int @@ -50,4 +51,21 @@ final readonly class ScheduleAssignment { 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; + } } diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php index 3cd5577..33b2953 100644 --- a/backend/app/Schedule/ScheduleAssignmentModel.php +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -2,6 +2,7 @@ namespace App\Schedule; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property list $element_path * @property string $scheduled_date * @property int $position + * @property DateTimeImmutable|null $completed_at * @property-read ScheduleModel $schedule * * @method static Builder|ScheduleAssignmentModel newModelQuery() @@ -30,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'element_path', 'scheduled_date', 'position', + 'completed_at', ])] class ScheduleAssignmentModel extends Model { @@ -46,6 +49,7 @@ class ScheduleAssignmentModel extends Model 'schedule_id' => 'integer', 'element_path' => 'array', 'position' => 'integer', + 'completed_at' => 'immutable_datetime', ]; } diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index fcdcb8c..3222311 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -23,4 +23,13 @@ interface ScheduleRepository User $user, DateTimeImmutable $date, ): array; + + public function findAssignmentForUser( + int $id, + User $user, + ): ?ScheduleAssignment; + + public function updateAssignment( + ScheduleAssignment $assignment, + ): ScheduleAssignment; } diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php new file mode 100644 index 0000000..df66b8d --- /dev/null +++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php new file mode 100644 index 0000000..e591375 --- /dev/null +++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php @@ -0,0 +1,14 @@ +request->input($key); + + return is_bool($value) ? $value : null; + } } diff --git a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php index b52c60a..c98992a 100644 --- a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php +++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php @@ -20,10 +20,12 @@ return new class extends Migration $table->json('element_path'); $table->date('scheduled_date'); $table->unsignedInteger('position'); + $table->timestamp('completed_at')->nullable(); $table->unique(['schedule_id', 'position']); $table->index([ 'schedule_id', 'scheduled_date', + 'completed_at', 'position', ]); }, diff --git a/backend/routes/api.php b/backend/routes/api.php index 2355570..76caf16 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -16,6 +16,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/sets/{setId}', [SetController::class, 'show']) ->whereNumber('setId'); Route::get('/assignments', [ScheduleController::class, 'assignments']); + Route::patch( + '/assignments/{assignmentId}', + [ScheduleController::class, 'updateAssignment'], + )->whereNumber('assignmentId'); Route::post('/schedules', [ScheduleController::class, 'store']); Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index e30caf2..e2f35bc 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -9,6 +9,7 @@ use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; use App\User\User; use DateTimeImmutable; +use DomainException; class FakeScheduleRepository implements ScheduleRepository { @@ -17,6 +18,8 @@ class FakeScheduleRepository implements ScheduleRepository */ private array $schedules = []; + private int $nextAssignmentId = 1; + public function create(CreateScheduleDto $dto): Schedule { $id = count($this->schedules) + 1; @@ -24,12 +27,13 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($dto->assignments as $assignmentDto) { $assignments[] = new ScheduleAssignment( - id: count($assignments) + 1, + id: $this->nextAssignmentId++, name: $assignmentDto->name, kind: $assignmentDto->kind, path: $assignmentDto->path, scheduledDate: $assignmentDto->scheduledDate, position: $assignmentDto->position, + completedAt: null, ); } @@ -84,6 +88,7 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($schedule->getAssignments() as $assignment) { if ($assignment->getScheduledDate()->format('Y-m-d') !== $date->format('Y-m-d') + || $assignment->getCompletedAt() !== null ) { continue; } @@ -99,21 +104,66 @@ class FakeScheduleRepository implements ScheduleRepository 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 { - $assignments = array_map( - function (ScheduleAssignment $assignment): ScheduleAssignment { - return new ScheduleAssignment( - id: $assignment->getId(), - name: $assignment->getName(), - kind: $assignment->getKind(), - path: $assignment->getPath(), - scheduledDate: $assignment->getScheduledDate(), - position: $assignment->getPosition(), - ); - }, - $schedule->getAssignments(), - ); + $assignments = array_map(function ( + ScheduleAssignment $assignment, + ): ScheduleAssignment { + return $this->copyAssignment($assignment); + }, $schedule->getAssignments()); return new Schedule( id: $schedule->getId(), @@ -125,4 +175,18 @@ class FakeScheduleRepository implements ScheduleRepository 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(), + ); + } } From 0d9e2fa71bf772e0690397d3decbc3780cc0f1a6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 22:47:49 +0300 Subject: [PATCH 3/5] test assignment completion ui --- .../website/cypress/e2e/set-scheduling.cy.ts | 87 +++++++++++++++++++ .../cypress/e2e/today-assignments.cy.ts | 68 +++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index a12f672..9f83629 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -74,6 +74,7 @@ const scheduleDetail = { assignments: [ { id: 1, + completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', @@ -88,6 +89,7 @@ const scheduleDetail = { assignments: [ { id: 2, + completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', @@ -100,6 +102,25 @@ 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 { cy.intercept('GET', '**/api/me', { statusCode: 200, @@ -227,6 +248,65 @@ describe('set scheduling', () => { 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', () => { cy.intercept('GET', '**/api/sets', { statusCode: 200, @@ -272,3 +352,10 @@ describe('set scheduling', () => { 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)) +} diff --git a/frontend/website/cypress/e2e/today-assignments.cy.ts b/frontend/website/cypress/e2e/today-assignments.cy.ts index 9d31dde..77d2ba5 100644 --- a/frontend/website/cypress/e2e/today-assignments.cy.ts +++ b/frontend/website/cypress/e2e/today-assignments.cy.ts @@ -113,6 +113,74 @@ 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', () => { let requestCount = 0 cy.intercept( From 9f5324598789a0ddea2feee312bab3374264c692 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 15 Aug 2026 22:52:13 +0300 Subject: [PATCH 4/5] add assignment completion ui --- frontend/website/src/stores/schedules.ts | 101 +++++++++++- frontend/website/src/views/DashboardView.vue | 101 ++++++++++-- .../website/src/views/ScheduleDetailView.vue | 155 +++++++++++++++++- 3 files changed, 335 insertions(+), 22 deletions(-) diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 090db7c..2471d66 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -17,7 +17,7 @@ export const scheduleSummarySchema = z.object({ assignmentCount: z.number().int().nonnegative(), }) -const scheduleAssignmentSchema = z.object({ +const assignmentIdentitySchema = z.object({ id: z.number().int().positive(), element: z.object({ name: z.string().min(1), @@ -26,7 +26,11 @@ const scheduleAssignmentSchema = z.object({ }), }) -export const assignmentForDateSchema = scheduleAssignmentSchema.extend({ +const scheduleAssignmentSchema = assignmentIdentitySchema.extend({ + completedAt: z.string().datetime({ offset: true }).nullable(), +}) + +export const assignmentForDateSchema = assignmentIdentitySchema.extend({ schedule: z.object({ id: z.number().int().positive(), set: z.object({ @@ -57,6 +61,13 @@ const assignmentsForDateResponseSchema = z.object({ 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({ error: z.string().min(1), }) @@ -75,6 +86,7 @@ const LIST_ERROR = "We couldn't load your schedules." const DETAIL_ERROR = "We couldn't load this schedule." const CREATE_ERROR = "We couldn't create this schedule." const ASSIGNMENTS_ERROR = "We couldn't load today's assignments." +const COMPLETION_ERROR = "We couldn't update this assignment." export const useSchedulesStore = defineStore('schedules', () => { const schedules = ref([]) @@ -89,6 +101,8 @@ export const useSchedulesStore = defineStore('schedules', () => { const assignmentsForDate = ref([]) const assignmentsLoading = ref(false) const assignmentsError = ref(null) + const assignmentCompletionPendingIds = ref([]) + const assignmentCompletionErrors = ref>({}) let activeDetailRequestId = 0 let assignmentsRequestId = 0 @@ -274,6 +288,84 @@ export const useSchedulesStore = defineStore('schedules', () => { } } + async function setAssignmentCompleted( + assignmentId: number, + completed: boolean, + ): Promise { + 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 { schedules, listLoading, @@ -287,9 +379,14 @@ export const useSchedulesStore = defineStore('schedules', () => { assignmentsForDate, assignmentsLoading, assignmentsError, + assignmentCompletionPendingIds, + assignmentCompletionErrors, fetchSchedules, fetchSchedule, createSchedule, fetchAssignmentsForDate, + setAssignmentCompleted, + isAssignmentCompletionPending, + assignmentCompletionError, } }) diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index c99ccf4..822da86 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -35,6 +35,10 @@ function formatDate(value: string): string { timeZone: 'UTC', }).format(new Date(`${value}T00:00:00Z`)) } + +async function completeAssignment(assignmentId: number): Promise { + await schedulesStore.setAssignmentCompleted(assignmentId, true) +}