diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index f80461a..9ec6588 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -14,8 +14,6 @@ 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; @@ -28,7 +26,6 @@ class ScheduleController extends Controller private ListSchedules $listSchedules, private GetSchedule $getSchedule, private ListAssignmentsForDate $listAssignmentsForDate, - private SetAssignmentCompletion $setAssignmentCompletion, ) {} 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 */ @@ -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, * kind: string, * path: list @@ -229,8 +191,6 @@ 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 214e8a5..7aec036 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -5,7 +5,6 @@ namespace App\Schedule; use App\User\User; use DateTimeImmutable; use DateTimeZone; -use DomainException; use Illuminate\Support\Facades\DB; class EloquentScheduleRepository implements ScheduleRepository @@ -30,7 +29,6 @@ class EloquentScheduleRepository implements ScheduleRepository 'scheduled_date' => $assignmentDto->scheduledDate ->format('Y-m-d'), 'position' => $assignmentDto->position, - 'completed_at' => null, ]); } @@ -79,7 +77,6 @@ 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') @@ -98,43 +95,6 @@ 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 { - $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 { $assignmentModels = ScheduleAssignmentModel::query() @@ -169,9 +129,6 @@ 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), ); } @@ -179,10 +136,4 @@ 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 8f90435..482b45d 100644 --- a/backend/app/Schedule/ScheduleAssignment.php +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -4,7 +4,7 @@ namespace App\Schedule; use DateTimeImmutable; -final class ScheduleAssignment +final readonly class ScheduleAssignment { /** * @param list $path @@ -16,7 +16,6 @@ final class ScheduleAssignment private array $path, private DateTimeImmutable $scheduledDate, private int $position, - private ?DateTimeImmutable $completedAt, ) {} public function getId(): int @@ -51,21 +50,4 @@ final 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 33b2953..3cd5577 100644 --- a/backend/app/Schedule/ScheduleAssignmentModel.php +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -2,7 +2,6 @@ namespace App\Schedule; -use DateTimeImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -16,7 +15,6 @@ 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() @@ -32,7 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'element_path', 'scheduled_date', 'position', - 'completed_at', ])] class ScheduleAssignmentModel extends Model { @@ -49,7 +46,6 @@ 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 3222311..fcdcb8c 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -23,13 +23,4 @@ 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 deleted file mode 100644 index df66b8d..0000000 --- a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index e591375..0000000 --- a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php +++ /dev/null @@ -1,14 +0,0 @@ -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 c98992a..b52c60a 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,12 +20,10 @@ 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 76caf16..2355570 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -16,10 +16,6 @@ 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 e2f35bc..e30caf2 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -9,7 +9,6 @@ use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; use App\User\User; use DateTimeImmutable; -use DomainException; class FakeScheduleRepository implements ScheduleRepository { @@ -18,8 +17,6 @@ class FakeScheduleRepository implements ScheduleRepository */ private array $schedules = []; - private int $nextAssignmentId = 1; - public function create(CreateScheduleDto $dto): Schedule { $id = count($this->schedules) + 1; @@ -27,13 +24,12 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($dto->assignments as $assignmentDto) { $assignments[] = new ScheduleAssignment( - id: $this->nextAssignmentId++, + id: count($assignments) + 1, name: $assignmentDto->name, kind: $assignmentDto->kind, path: $assignmentDto->path, scheduledDate: $assignmentDto->scheduledDate, position: $assignmentDto->position, - completedAt: null, ); } @@ -88,7 +84,6 @@ 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; } @@ -104,66 +99,21 @@ 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 $this->copyAssignment($assignment); - }, $schedule->getAssignments()); + $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(), + ); return new Schedule( id: $schedule->getId(), @@ -175,18 +125,4 @@ 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(), - ); - } } diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index b68e2ad..a0e9cf8 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Schedule; use App\Auth\CreateSessionDto; -use App\Auth\Clock; use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementModel; @@ -26,7 +25,6 @@ use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Testing\TestResponse; use Tests\TestCase; -use Tests\Fakes\FakeClock; class ScheduleEndpointTest extends TestCase { @@ -96,7 +94,6 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 1, - 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -118,7 +115,6 @@ class ScheduleEndpointTest extends TestCase 'assignments' => [ [ 'id' => 2, - 'completedAt' => null, 'element' => [ 'name' => 'Chapter 1', 'kind' => 'chapter', @@ -355,130 +351,6 @@ 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'); @@ -583,9 +455,6 @@ 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); @@ -651,18 +520,4 @@ 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 deleted file mode 100644 index 12f86f5..0000000 --- a/backend/tests/Unit/Schedule/ScheduleAssignmentTest.php +++ /dev/null @@ -1,49 +0,0 @@ -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 c6bc95f..1d1e3fd 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; - $olderSchedule = $repository->create($this->schedule( + $repository->create($this->schedule( user: $user, setName: 'Older plan', date: '2026-08-15', @@ -46,12 +46,6 @@ 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( @@ -61,13 +55,13 @@ class ListAssignmentsForDateTest extends TestCase ); $this->assertSame( - ['Newer plan', 'Older plan'], + ['Newer plan', 'Older plan', 'Older plan'], array_map(function ($assignment): string { return $assignment->getSetName(); }, $assignments), ); $this->assertSame( - ['Third', 'Second'], + ['Third', 'First', '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 deleted file mode 100644 index 8950edc..0000000 --- a/backend/tests/Unit/Schedule/UseCases/SetAssignmentCompletionTest.php +++ /dev/null @@ -1,175 +0,0 @@ -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')); - } -} diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 9f83629..a12f672 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -74,7 +74,6 @@ const scheduleDetail = { assignments: [ { id: 1, - completedAt: null, element: { name: 'Chapter 1', kind: 'Chapter_sections-v2', @@ -89,7 +88,6 @@ const scheduleDetail = { assignments: [ { id: 2, - completedAt: null, element: { name: 'Chapter 1', 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 { cy.intercept('GET', '**/api/me', { statusCode: 200, @@ -248,65 +227,6 @@ 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, @@ -352,10 +272,3 @@ 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 77d2ba5..9d31dde 100644 --- a/frontend/website/cypress/e2e/today-assignments.cy.ts +++ b/frontend/website/cypress/e2e/today-assignments.cy.ts @@ -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', () => { let requestCount = 0 cy.intercept( diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 2471d66..090db7c 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 assignmentIdentitySchema = z.object({ +const scheduleAssignmentSchema = z.object({ id: z.number().int().positive(), element: z.object({ name: z.string().min(1), @@ -26,11 +26,7 @@ const assignmentIdentitySchema = z.object({ }), }) -const scheduleAssignmentSchema = assignmentIdentitySchema.extend({ - completedAt: z.string().datetime({ offset: true }).nullable(), -}) - -export const assignmentForDateSchema = assignmentIdentitySchema.extend({ +export const assignmentForDateSchema = scheduleAssignmentSchema.extend({ schedule: z.object({ id: z.number().int().positive(), set: z.object({ @@ -61,13 +57,6 @@ 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), }) @@ -86,7 +75,6 @@ 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([]) @@ -101,8 +89,6 @@ 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 @@ -288,84 +274,6 @@ 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, @@ -379,14 +287,9 @@ 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 822da86..c99ccf4 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -35,10 +35,6 @@ 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) -}