From 258e4ea27a2ff32cb77e1ee42c0940553e78434f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:41:03 +0300 Subject: [PATCH 01/10] test schedule rescheduling --- .../Feature/Schedule/ScheduleEndpointTest.php | 120 ++++++++++ .../UseCases/RescheduleScheduleTest.php | 218 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index ef98f8c..a6595c4 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -558,6 +558,125 @@ class ScheduleEndpointTest extends TestCase ->assertJsonCount(2, 'assignments'); } + public function test_it_reschedules_only_unfinished_assignments(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + $repository = app(ElementRepository::class); + foreach (range(1, 4) as $number) { + $repository->create(new CreateElementDto( + name: "Lesson {$number}", + 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-10', + 'targetDate' => '2026-08-13', + ])->assertCreated(); + $this->credentialedPatch('/api/assignments/1', [ + 'completed' => true, + ])->assertOk(); + + $response = $this->credentialedPatch('/api/schedules/1', [ + 'startDate' => '2026-08-20', + 'targetDate' => '2026-08-21', + 'workloadPlacement' => 'end', + ]); + + $response->assertOk() + ->assertJsonPath('schedule.startDate', '2026-08-20') + ->assertJsonPath('schedule.targetDate', '2026-08-21') + ->assertJsonPath('schedule.days.0.date', '2026-08-10') + ->assertJsonPath( + 'schedule.days.0.assignments.0.completedAt', + '2026-08-15T12:30:45+00:00', + ) + ->assertJsonPath('schedule.days.1.date', '2026-08-20') + ->assertJsonCount(1, 'schedule.days.1.assignments') + ->assertJsonPath('schedule.days.1.assignments.0.id', 2) + ->assertJsonPath('schedule.days.2.date', '2026-08-21') + ->assertJsonCount(2, 'schedule.days.2.assignments') + ->assertJsonPath('schedule.days.2.assignments.0.id', 3) + ->assertJsonPath('schedule.days.2.assignments.1.id', 4); + $this->assertDatabaseHas('schedules', [ + 'id' => 1, + 'start_date' => '2026-08-20', + 'target_date' => '2026-08-21', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 1, + 'scheduled_date' => '2026-08-10', + 'completed_at' => '2026-08-15 12:30:45', + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 2, + 'scheduled_date' => '2026-08-20', + 'completed_at' => null, + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 3, + 'scheduled_date' => '2026-08-21', + 'completed_at' => null, + ]); + $this->assertDatabaseHas('schedule_assignments', [ + 'id' => 4, + 'scheduled_date' => '2026-08-21', + 'completed_at' => null, + ]); + } + + public function test_it_hides_another_users_schedule_when_rescheduling(): 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-10', + 'targetDate' => '2026-08-10', + ])->assertCreated(); + + $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'viewer-token', + )->patchJson('/api/schedules/1', [ + 'startDate' => '2026-08-20', + 'targetDate' => '2026-08-21', + 'workloadPlacement' => 'middle', + ])->assertNotFound()->assertExactJson([ + 'error' => 'schedule not found', + ]); + $this->assertDatabaseHas('schedules', [ + 'id' => 1, + 'start_date' => '2026-08-10', + 'target_date' => '2026-08-10', + ]); + } + public function test_it_rejects_invalid_assignment_completion_input(): void { $user = $this->createUser('reader@example.com'); @@ -718,6 +837,7 @@ class ScheduleEndpointTest extends TestCase ])->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401); + $this->patchJson('/api/schedules/1', [])->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401); } diff --git a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php new file mode 100644 index 0000000..77daf80 --- /dev/null +++ b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php @@ -0,0 +1,218 @@ +user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + $assignments = $schedule->getAssignments(); + $firstCompletion = $this->utc('2026-08-15T10:00:00'); + $secondCompletion = $this->utc('2026-08-16T11:00:00'); + $assignments[1]->complete($firstCompletion); + $assignments[3]->complete($secondCompletion); + $repository->updateAssignment($assignments[1]); + $repository->updateAssignment($assignments[3]); + + $rescheduled = $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'end', + ), + ); + + $this->assertSame('2026-08-20', $rescheduled->getStartDate() + ->format('Y-m-d')); + $this->assertSame('2026-08-21', $rescheduled->getTargetDate() + ->format('Y-m-d')); + $this->assertSame( + [ + '2026-08-20', + '2026-08-11', + '2026-08-21', + '2026-08-13', + '2026-08-21', + ], + array_map(function (ScheduleAssignment $assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + [null, $firstCompletion, null, $secondCompletion, null], + array_map(function ( + ScheduleAssignment $assignment, + ): ?DateTimeImmutable { + return $assignment->getCompletedAt(); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + [1, 2, 3, 4, 5], + array_map(function (ScheduleAssignment $assignment): int { + return $assignment->getId(); + }, $rescheduled->getAssignments()), + ); + $this->assertSame( + ['Lesson 1', 'Lesson 2', 'Lesson 3', 'Lesson 4', 'Lesson 5'], + array_map(function (ScheduleAssignment $assignment): string { + return $assignment->getName(); + }, $rescheduled->getAssignments()), + ); + } + + public function test_it_rejects_a_target_before_the_start(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'targetDate must not be before startDate', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-21', + targetDate: '2026-08-20', + workloadPlacement: 'middle', + ), + ); + } + + public function test_it_rejects_an_unknown_workload_placement(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'workloadPlacement must be start, middle, or end', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'sideways', + ), + ); + } + + public function test_it_rejects_a_schedule_without_unfinished_work(): void + { + $user = $this->user(1, 'reader@example.com'); + $repository = new FakeScheduleRepository; + $schedule = $repository->create($this->schedule($user)); + foreach ($schedule->getAssignments() as $assignment) { + $assignment->complete($this->utc('2026-08-15T10:00:00')); + $repository->updateAssignment($assignment); + } + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'schedule has no unfinished assignments', + ); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $user, + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'middle', + ), + ); + } + + public function test_it_hides_another_users_schedule(): void + { + $repository = new FakeScheduleRepository; + $owner = $this->user(1, 'owner@example.com'); + $schedule = $repository->create($this->schedule($owner)); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('schedule not found'); + + $this->useCase($repository)->execute( + new RescheduleScheduleRequest( + scheduleId: $schedule->getId(), + user: $this->user(2, 'viewer@example.com'), + startDate: '2026-08-20', + targetDate: '2026-08-21', + workloadPlacement: 'middle', + ), + ); + } + + private function useCase( + FakeScheduleRepository $repository, + ): RescheduleSchedule { + return new RescheduleSchedule( + $repository, + new EvenDistributionScheduler, + ); + } + + private function schedule(User $user): CreateScheduleDto + { + $assignments = []; + foreach (range(1, 5) as $number) { + $assignments[] = new CreateScheduleAssignmentDto( + name: "Lesson {$number}", + kind: 'lesson', + path: ['Course', "Lesson {$number}"], + scheduledDate: $this->utc("2026-08-1{$number}"), + position: $number, + ); + } + + return new CreateScheduleDto( + user: $user, + setName: 'Course', + elementKind: 'lesson', + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-15'), + assignments: $assignments, + ); + } + + 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 b8eeb093d3ef620808e433be7d612d428dd61067 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:42:55 +0300 Subject: [PATCH 02/10] fix reschedule test dates --- .../tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php index 77daf80..3bc2b6b 100644 --- a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php @@ -49,9 +49,9 @@ class RescheduleScheduleTest extends TestCase $this->assertSame( [ '2026-08-20', - '2026-08-11', + '2026-08-12', '2026-08-21', - '2026-08-13', + '2026-08-14', '2026-08-21', ], array_map(function (ScheduleAssignment $assignment): string { From 7576ccaf09b901e08b2cf0d1c5f834fa8206183b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:44:14 +0300 Subject: [PATCH 03/10] add schedule rescheduling --- .../Http/Controllers/ScheduleController.php | 52 ++++++- .../Schedule/EloquentScheduleRepository.php | 33 +++++ .../RescheduleScheduleAssignmentDto.php | 13 ++ .../app/Schedule/RescheduleScheduleDto.php | 20 +++ backend/app/Schedule/ScheduleRepository.php | 2 + .../RescheduleSchedule/RescheduleSchedule.php | 132 ++++++++++++++++++ .../RescheduleScheduleRequest.php | 16 +++ backend/routes/api.php | 4 + .../tests/Fakes/FakeScheduleRepository.php | 47 ++++++- 9 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 backend/app/Schedule/RescheduleScheduleAssignmentDto.php create mode 100644 backend/app/Schedule/RescheduleScheduleDto.php create mode 100644 backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php create mode 100644 backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index c1f8d12..d37266f 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -11,9 +11,11 @@ use App\Schedule\UseCases\CreateSchedule\CreateSchedule; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetSchedule; 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\ListSchedules\ListSchedules; +use App\Schedule\UseCases\RescheduleSchedule\RescheduleSchedule; +use App\Schedule\UseCases\RescheduleSchedule\RescheduleScheduleRequest; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest; use App\Shared\Http\RequestInput; @@ -27,6 +29,7 @@ class ScheduleController extends Controller private CreateSchedule $createSchedule, private ListSchedules $listSchedules, private GetSchedule $getSchedule, + private RescheduleSchedule $rescheduleSchedule, private ListAssignmentsForDate $listAssignmentsForDate, private SetAssignmentCompletion $setAssignmentCompletion, ) {} @@ -100,6 +103,39 @@ class ScheduleController extends Controller ]); } + public function update(Request $request, int $scheduleId): JsonResponse + { + $input = new RequestInput($request); + + try { + $schedule = $this->rescheduleSchedule->execute( + new RescheduleScheduleRequest( + scheduleId: $scheduleId, + user: $this->user($request), + startDate: $input->string('startDate'), + targetDate: $input->string('targetDate'), + workloadPlacement: $input->string( + 'workloadPlacement', + ), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse([ + 'schedule' => $this->detailPayload($schedule), + ]); + } + public function assignments(Request $request): JsonResponse { $input = new RequestInput($request); @@ -206,15 +242,23 @@ class ScheduleController extends Controller ); } - $days = []; + $includedDates = []; $date = $schedule->getStartDate(); while ($date <= $schedule->getTargetDate()) { - $formattedDate = $date->format('Y-m-d'); + $includedDates[$date->format('Y-m-d')] = true; + $date = $date->modify('+1 day'); + } + foreach (array_keys($assignmentsByDate) as $assignmentDate) { + $includedDates[$assignmentDate] = true; + } + ksort($includedDates); + + $days = []; + foreach (array_keys($includedDates) as $formattedDate) { $days[] = [ 'date' => $formattedDate, 'assignments' => $assignmentsByDate[$formattedDate] ?? [], ]; - $date = $date->modify('+1 day'); } return $days; diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 8576591..69f08a5 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -38,6 +38,39 @@ class EloquentScheduleRepository implements ScheduleRepository }); } + public function reschedule(RescheduleScheduleDto $dto): Schedule + { + return DB::transaction(function () use ($dto): Schedule { + $model = ScheduleModel::query() + ->where('id', $dto->scheduleId) + ->where('user_id', $dto->user->getId()) + ->lockForUpdate() + ->first(); + if ($model === null) { + throw new DomainException( + "Schedule with id {$dto->scheduleId} not found", + ); + } + + $model->start_date = $dto->startDate->format('Y-m-d'); + $model->target_date = $dto->targetDate->format('Y-m-d'); + $model->save(); + + foreach ($dto->assignments as $assignmentDto) { + ScheduleAssignmentModel::query() + ->where('id', $assignmentDto->id) + ->where('schedule_id', $dto->scheduleId) + ->whereNull('completed_at') + ->update([ + 'scheduled_date' => $assignmentDto->scheduledDate + ->format('Y-m-d'), + ]); + } + + return $this->toDomain($model, $dto->user); + }); + } + public function findForUser(int $id, User $user): ?Schedule { $model = ScheduleModel::query() diff --git a/backend/app/Schedule/RescheduleScheduleAssignmentDto.php b/backend/app/Schedule/RescheduleScheduleAssignmentDto.php new file mode 100644 index 0000000..510a127 --- /dev/null +++ b/backend/app/Schedule/RescheduleScheduleAssignmentDto.php @@ -0,0 +1,13 @@ + $assignments + */ + public function __construct( + public int $scheduleId, + public User $user, + public DateTimeImmutable $startDate, + public DateTimeImmutable $targetDate, + public array $assignments, + ) {} +} diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index 20b5f9f..d422a68 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -9,6 +9,8 @@ interface ScheduleRepository { public function create(CreateScheduleDto $dto): Schedule; + public function reschedule(RescheduleScheduleDto $dto): Schedule; + public function findForUser(int $id, User $user): ?Schedule; /** diff --git a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php new file mode 100644 index 0000000..407a4c7 --- /dev/null +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php @@ -0,0 +1,132 @@ +scheduleRepository->findForUser( + $request->scheduleId, + $request->user, + ); + if ($schedule === null) { + throw new NotFoundException('schedule not found'); + } + + $startDate = $this->parseDate($request->startDate, 'startDate'); + $targetDate = $this->parseDate($request->targetDate, 'targetDate'); + if ($targetDate < $startDate) { + throw new BadRequestException( + 'targetDate must not be before startDate', + ); + } + $workloadPlacement = $this->workloadPlacement( + $request->workloadPlacement, + ); + $unfinishedAssignments = array_values(array_filter( + $schedule->getAssignments(), + function (ScheduleAssignment $assignment): bool { + return $assignment->getCompletedAt() === null; + }, + )); + if ($unfinishedAssignments === []) { + throw new BadRequestException( + 'schedule has no unfinished assignments', + ); + } + + $scheduledDates = $this->evenDistributionScheduler->scheduledDates( + assignmentCount: count($unfinishedAssignments), + startDate: $startDate, + targetDate: $targetDate, + workloadPlacement: $workloadPlacement, + ); + $assignmentDtos = []; + foreach ($unfinishedAssignments as $index => $assignment) { + $assignmentDtos[] = new RescheduleScheduleAssignmentDto( + id: $assignment->getId(), + scheduledDate: $scheduledDates[$index], + ); + } + + return $this->scheduleRepository->reschedule( + new RescheduleScheduleDto( + scheduleId: $schedule->getId(), + user: $request->user, + startDate: $startDate, + targetDate: $targetDate, + assignments: $assignmentDtos, + ), + ); + } + + /** + * @throws BadRequestException + */ + private function parseDate(?string $value, string $field): DateTimeImmutable + { + if ($value === null || $value === '') { + throw new BadRequestException("{$field} is required"); + } + + $date = DateTimeImmutable::createFromFormat( + '!Y-m-d', + $value, + new DateTimeZone('UTC'), + ); + $errors = DateTimeImmutable::getLastErrors(); + if ( + $date === false + || $date->format('Y-m-d') !== $value + || ($errors !== false + && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + ) { + throw new BadRequestException( + "{$field} must be a valid date in YYYY-MM-DD format", + ); + } + + return $date; + } + + /** + * @throws BadRequestException + */ + private function workloadPlacement(?string $value): WorkloadPlacement + { + if ($value === null) { + return WorkloadPlacement::Middle; + } + + $workloadPlacement = WorkloadPlacement::tryFrom($value); + if ($workloadPlacement === null) { + throw new BadRequestException( + 'workloadPlacement must be start, middle, or end', + ); + } + + return $workloadPlacement; + } +} diff --git a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php new file mode 100644 index 0000000..d23468e --- /dev/null +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php @@ -0,0 +1,16 @@ +group(function (): void { )->whereNumber('assignmentId'); Route::post('/schedules', [ScheduleController::class, 'store']); Route::get('/schedules', [ScheduleController::class, 'index']); + Route::patch( + '/schedules/{scheduleId}', + [ScheduleController::class, 'update'], + )->whereNumber('scheduleId'); Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) ->whereNumber('scheduleId'); Route::post('/logout', [AuthController::class, 'logout']); diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index e12d126..2713d30 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -2,8 +2,9 @@ namespace Tests\Fakes; -use App\Schedule\CreateScheduleDto; use App\Schedule\AssignmentForDate; +use App\Schedule\CreateScheduleDto; +use App\Schedule\RescheduleScheduleDto; use App\Schedule\Schedule; use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; @@ -51,6 +52,50 @@ class FakeScheduleRepository implements ScheduleRepository return $this->copy($schedule); } + public function reschedule(RescheduleScheduleDto $dto): Schedule + { + $schedule = $this->schedules[$dto->scheduleId] ?? null; + if ($schedule === null || $schedule->getUser()->getId() + !== $dto->user->getId() + ) { + throw new DomainException( + "Schedule with id {$dto->scheduleId} not found", + ); + } + + $datesByAssignmentId = []; + foreach ($dto->assignments as $assignmentDto) { + $datesByAssignmentId[$assignmentDto->id] = + $assignmentDto->scheduledDate; + } + $assignments = []; + foreach ($schedule->getAssignments() as $assignment) { + $assignments[] = new ScheduleAssignment( + id: $assignment->getId(), + name: $assignment->getName(), + kind: $assignment->getKind(), + path: $assignment->getPath(), + scheduledDate: $datesByAssignmentId[$assignment->getId()] + ?? $assignment->getScheduledDate(), + position: $assignment->getPosition(), + completedAt: $assignment->getCompletedAt(), + ); + } + + $rescheduled = new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + setName: $schedule->getSetName(), + elementKind: $schedule->getElementKind(), + startDate: $dto->startDate, + targetDate: $dto->targetDate, + assignments: $assignments, + ); + $this->schedules[$dto->scheduleId] = $rescheduled; + + return $this->copy($rescheduled); + } + public function findForUser(int $id, User $user): ?Schedule { $schedule = $this->schedules[$id] ?? null; From 6ab6ed801a2efcdc072b8f65d10d47576d77300a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:48:03 +0300 Subject: [PATCH 04/10] test reschedule form --- .../website/cypress/e2e/set-scheduling.cy.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index b799db0..d138e28 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -184,6 +184,103 @@ const splitScheduleDetail = { }, } +const rescheduleSourceDetail = { + schedule: { + ...scheduleDetail.schedule, + startDate: '2026-08-10', + targetDate: '2026-08-20', + assignmentCount: 4, + days: [ + { + date: '2026-08-10', + assignments: [ + { + ...scheduleDetail.schedule.days[0]?.assignments[0], + completedAt: '2026-08-11T08:30:00+00:00', + }, + ], + }, + { + date: '2026-08-18', + assignments: [ + { + id: 2, + completedAt: null, + element: { + name: 'Chapter 2', + kind: 'Chapter_sections-v2', + path: ['Genesis', 'Creation', 'Chapter 2'], + }, + }, + ], + }, + { + date: '2026-08-19', + assignments: [ + { + id: 3, + completedAt: null, + element: { + name: 'Chapter 3', + kind: 'Chapter_sections-v2', + path: ['Genesis', 'Creation', 'Chapter 3'], + }, + }, + ], + }, + { + date: '2026-08-20', + assignments: [ + { + id: 4, + completedAt: null, + element: { + name: 'Chapter 4', + kind: 'Chapter_sections-v2', + path: ['Genesis', 'Creation', 'Chapter 4'], + }, + }, + ], + }, + ], + }, +} + +const rescheduledDetail = { + schedule: { + ...rescheduleSourceDetail.schedule, + startDate: '2026-08-15', + targetDate: '2026-08-16', + days: [ + rescheduleSourceDetail.schedule.days[0], + { + date: '2026-08-15', + assignments: [rescheduleSourceDetail.schedule.days[1]?.assignments[0]], + }, + { + date: '2026-08-16', + assignments: [ + rescheduleSourceDetail.schedule.days[2]?.assignments[0], + rescheduleSourceDetail.schedule.days[3]?.assignments[0], + ], + }, + ], + }, +} + +const fullyCompletedScheduleDetail = { + schedule: { + ...scheduleDetail.schedule, + days: scheduleDetail.schedule.days.map((day) => ({ + ...day, + assignments: day.assignments.map((assignment) => ({ + ...assignment, + completedAt: '2026-08-15T09:30:00+00:00', + })), + })), + }, +} + function interceptAuthenticatedUser(): void { cy.intercept('GET', '**/api/me', { statusCode: 200, @@ -458,6 +555,99 @@ describe('set scheduling', () => { }) }) + it('reschedules unfinished assignments while preserving completed work', () => { + cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date']) + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: rescheduleSourceDetail, + }).as('schedule') + cy.intercept('PATCH', '**/api/schedules/73', (request) => { + expect(request.headers.accept).to.equal('application/json') + expect(request.body).to.deep.equal({ + startDate: '2026-08-15', + targetDate: '2026-08-16', + workloadPlacement: 'end', + }) + request.reply({ statusCode: 200, body: rescheduledDetail }) + }).as('reschedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + cy.get('[data-reschedule-form]').should('not.exist') + cy.get('[data-reschedule-toggle]').should('have.text', 'Reschedule remaining').click() + + cy.get('#reschedule-start-date').should('have.value', '2026-08-15') + cy.get('#reschedule-target-date').should('have.value', '2026-08-20') + cy.get('[data-workload-placement]').should('not.exist') + cy.get('#reschedule-target-date').clear().type('2026-08-16') + cy.get('[data-workload-placement]').should('be.visible').within(() => { + cy.get('input[value="middle"]').should('be.checked') + cy.get('input[value="end"]').check() + }) + cy.get('[data-reschedule-form]').submit() + cy.wait('@reschedule') + + cy.get('[data-reschedule-form]').should('not.exist') + cy.get('[data-reschedule-announcement]').should( + 'have.text', + '3 remaining assignments rescheduled.', + ) + cy.contains('4 Chapter_sections-v2 assignments across 2 days').should('be.visible') + cy.get('.schedule-range').should('contain.text', 'Aug 15, 2026 to Aug 16, 2026') + cy.get('[data-assignment-section="remaining"] [data-schedule-day]').then(($days) => { + expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([ + '2026-08-15', + '2026-08-16', + ]) + }) + cy.get('[data-assignment-section="completed"] summary').click() + cy.get('[data-assignment-section="completed"] [data-schedule-day]') + .should('have.length', 1) + .and('have.attr', 'data-schedule-date', '2026-08-10') + + cy.get('[data-reschedule-toggle]').click() + cy.get('#reschedule-start-date').should('have.value', '2026-08-15') + cy.get('#reschedule-target-date').should('have.value', '2026-08-16') + }) + + it('uses today for an expired target and validates before rescheduling', () => { + cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date']) + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: scheduleDetail, + }).as('schedule') + cy.intercept('PATCH', '**/api/schedules/73').as('reschedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + cy.get('[data-reschedule-toggle]').click() + + cy.get('#reschedule-start-date').should('have.value', '2026-08-15') + cy.get('#reschedule-target-date').should('have.value', '2026-08-15') + cy.get('#reschedule-target-date').clear().type('2026-08-14') + cy.get('[data-reschedule-form]').submit() + cy.get('#reschedule-target-date-error').should( + 'have.text', + 'Target date cannot be before the start date.', + ) + cy.get('@reschedule.all').should('have.length', 0) + }) + + it('does not offer rescheduling after all assignments are complete', () => { + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: fullyCompletedScheduleDetail, + }).as('schedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + + cy.get('[data-reschedule-toggle]').should('not.exist') + }) + it('completes and reopens assignments from the schedule', () => { const completedAt = '2026-08-15T12:30:00+00:00' cy.intercept('GET', '**/api/schedules/73', { From 3a34249e0e44ad4c55bb705f1cea9662b1badff1 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:56:33 +0300 Subject: [PATCH 05/10] cover reschedule failure states --- .../website/cypress/e2e/set-scheduling.cy.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index d138e28..36f52e0 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -585,6 +585,12 @@ describe('set scheduling', () => { cy.get('input[value="middle"]').should('be.checked') cy.get('input[value="end"]').check() }) + cy.viewport(375, 667) + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) cy.get('[data-reschedule-form]').submit() cy.wait('@reschedule') @@ -648,6 +654,31 @@ describe('set scheduling', () => { cy.get('[data-reschedule-toggle]').should('not.exist') }) + it('keeps the current plan visible when rescheduling fails', () => { + cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date']) + cy.intercept('GET', '**/api/schedules/73', { + statusCode: 200, + body: rescheduleSourceDetail, + }).as('schedule') + cy.intercept('PATCH', '**/api/schedules/73', { + statusCode: 500, + body: { error: 'Rescheduling is temporarily unavailable.' }, + }).as('reschedule') + + cy.visit('/schedules/73') + cy.wait('@me') + cy.wait('@schedule') + cy.get('[data-reschedule-toggle]').click() + cy.get('[data-reschedule-form]').submit() + cy.wait('@reschedule') + + cy.get('[data-reschedule-form]') + .should('be.visible') + .and('contain.text', 'Rescheduling is temporarily unavailable.') + cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 20, 2026') + cy.get('[data-reschedule-announcement]').should('have.text', '') + }) + it('completes and reopens assignments from the schedule', () => { const completedAt = '2026-08-15T12:30:00+00:00' cy.intercept('GET', '**/api/schedules/73', { From db6b41ee42813eec0901531e80e3db1da79edb71 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:57:43 +0300 Subject: [PATCH 06/10] add reschedule form --- .../src/components/ScheduleRescheduleForm.vue | 358 ++++++++++++++++++ frontend/website/src/stores/schedules.ts | 62 +++ .../website/src/views/ScheduleDetailView.vue | 138 ++++++- 3 files changed, 552 insertions(+), 6 deletions(-) create mode 100644 frontend/website/src/components/ScheduleRescheduleForm.vue diff --git a/frontend/website/src/components/ScheduleRescheduleForm.vue b/frontend/website/src/components/ScheduleRescheduleForm.vue new file mode 100644 index 0000000..e61e166 --- /dev/null +++ b/frontend/website/src/components/ScheduleRescheduleForm.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index c6c3fde..75d3c63 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -86,10 +86,16 @@ export type CreateScheduleInput = { targetDate: string workloadPlacement: WorkloadPlacement } +export type RescheduleScheduleInput = { + startDate: string + targetDate: string + workloadPlacement: WorkloadPlacement +} 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 RESCHEDULE_ERROR = "We couldn't reschedule this schedule." const ASSIGNMENTS_ERROR = "We couldn't load today's assignments." const COMPLETION_ERROR = "We couldn't update this assignment." @@ -103,6 +109,8 @@ export const useSchedulesStore = defineStore('schedules', () => { const detailNotFound = ref(false) const creating = ref(false) const createError = ref(null) + const rescheduling = ref(false) + const rescheduleError = ref(null) const assignmentsForDate = ref([]) const assignmentsLoading = ref(false) const assignmentsError = ref(null) @@ -241,6 +249,56 @@ export const useSchedulesStore = defineStore('schedules', () => { } } + async function rescheduleSchedule( + scheduleId: number, + input: RescheduleScheduleInput, + ): Promise { + rescheduling.value = true + rescheduleError.value = null + + try { + const response = await fetch(`${API_BASE}/api/schedules/${scheduleId}`, { + method: 'PATCH', + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }) + const responseBody: unknown = await response.json() + + if (response.status !== 200) { + const parsedError = errorResponseSchema.safeParse(responseBody) + rescheduleError.value = parsedError.success ? parsedError.data.error : RESCHEDULE_ERROR + + return null + } + + const rescheduled = scheduleResponseSchema.parse(responseBody).schedule + if (rescheduled.id !== scheduleId) { + throw new Error('schedule response did not match requested schedule') + } + activeSchedule.value = rescheduled + const summary = scheduleSummarySchema.parse(rescheduled) + schedules.value = schedules.value.map((schedule) => + schedule.id === scheduleId ? summary : schedule, + ) + + return rescheduled + } catch { + rescheduleError.value = RESCHEDULE_ERROR + + return null + } finally { + rescheduling.value = false + } + } + + function clearRescheduleError(): void { + rescheduleError.value = null + } + async function fetchAssignmentsForDate(date: string): Promise { const requestId = ++assignmentsRequestId assignmentsForDate.value = [] @@ -381,6 +439,8 @@ export const useSchedulesStore = defineStore('schedules', () => { detailNotFound, creating, createError, + rescheduling, + rescheduleError, assignmentsForDate, assignmentsLoading, assignmentsError, @@ -389,6 +449,8 @@ export const useSchedulesStore = defineStore('schedules', () => { fetchSchedules, fetchSchedule, createSchedule, + rescheduleSchedule, + clearRescheduleError, fetchAssignmentsForDate, setAssignmentCompleted, isAssignmentCompletionPending, diff --git a/frontend/website/src/views/ScheduleDetailView.vue b/frontend/website/src/views/ScheduleDetailView.vue index 5c72243..bf700ac 100644 --- a/frontend/website/src/views/ScheduleDetailView.vue +++ b/frontend/website/src/views/ScheduleDetailView.vue @@ -5,7 +5,12 @@ import { useRoute } from 'vue-router' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.vue' -import { useSchedulesStore, type ScheduleDetail } from '@/stores/schedules' +import ScheduleRescheduleForm from '@/components/ScheduleRescheduleForm.vue' +import { + useSchedulesStore, + type RescheduleScheduleInput, + type ScheduleDetail, +} from '@/stores/schedules' type ScheduleDay = ScheduleDetail['days'][number] @@ -18,9 +23,13 @@ const { detailLoading, detailError, detailNotFound, + rescheduling, + rescheduleError, } = storeToRefs(schedulesStore) const currentScheduleId = ref(null) const completionAnnouncement = ref('') +const rescheduleAnnouncement = ref('') +const rescheduleFormOpen = ref(false) const todayDate = browserDate(new Date()) const remainingDays = computed(() => { @@ -28,9 +37,12 @@ const remainingDays = computed(() => { return [] } - return activeSchedule.value.days.flatMap((day) => { + const schedule = activeSchedule.value + + return schedule.days.flatMap((day) => { const assignments = day.assignments.filter((assignment) => assignment.completedAt === null) - const isRestDay = day.assignments.length === 0 + const isActiveDate = day.date >= schedule.startDate && day.date <= schedule.targetDate + const isRestDay = isActiveDate && day.assignments.length === 0 return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : [] }) @@ -50,6 +62,13 @@ const completedDays = computed(() => { const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value)) const completedAssignmentCount = computed(() => assignmentCount(completedDays.value)) +const activeDayCount = computed(() => { + if (activeSchedule.value === null) { + return 0 + } + + return inclusiveDayCount(activeSchedule.value.startDate, activeSchedule.value.targetDate) +}) watch( () => route.params.scheduleId, @@ -59,6 +78,9 @@ watch( : scheduleIdParameter const scheduleId = Number(rawScheduleId) currentScheduleId.value = scheduleId + rescheduleFormOpen.value = false + rescheduleAnnouncement.value = '' + schedulesStore.clearRescheduleError() if (activeSchedule.value?.id !== scheduleId) { await schedulesStore.fetchSchedule(scheduleId) @@ -92,6 +114,48 @@ function assignmentCount(days: ScheduleDay[]): number { return days.reduce((count, day) => count + day.assignments.length, 0) } +function inclusiveDayCount(startDate: string, targetDate: string): number { + const startTime = Date.parse(`${startDate}T00:00:00Z`) + const targetTime = Date.parse(`${targetDate}T00:00:00Z`) + if (Number.isNaN(startTime) || Number.isNaN(targetTime) || targetTime < startTime) { + return 0 + } + + const millisecondsPerDay = 24 * 60 * 60 * 1000 + + return Math.round((targetTime - startTime) / millisecondsPerDay) + 1 +} + +function openRescheduleForm(): void { + if (activeSchedule.value === null) { + return + } + + rescheduleAnnouncement.value = '' + schedulesStore.clearRescheduleError() + rescheduleFormOpen.value = true +} + +function cancelReschedule(): void { + schedulesStore.clearRescheduleError() + rescheduleFormOpen.value = false +} + +async function submitReschedule(input: RescheduleScheduleInput): Promise { + if (currentScheduleId.value === null) { + return + } + + const rescheduledAssignmentCount = remainingAssignmentCount.value + const schedule = await schedulesStore.rescheduleSchedule(currentScheduleId.value, input) + if (schedule !== null) { + rescheduleFormOpen.value = false + rescheduleAnnouncement.value = `${rescheduledAssignmentCount} remaining ${ + rescheduledAssignmentCount === 1 ? 'assignment' : 'assignments' + } rescheduled.` + } +} + async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise { completionAnnouncement.value = '' const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed) @@ -136,15 +200,47 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean): {{ activeSchedule.assignmentCount }} {{ activeSchedule.elementKind }} {{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across - {{ activeSchedule.days.length }} - {{ activeSchedule.days.length === 1 ? 'day' : 'days' }} + {{ activeDayCount }} + {{ activeDayCount === 1 ? 'day' : 'days' }}

{{ formatDate(activeSchedule.startDate) }} to {{ formatDate(activeSchedule.targetDate) }}

+ +

+ {{ rescheduleAnnouncement }} +

+ + +

Date: Sat, 22 Aug 2026 21:58:59 +0300 Subject: [PATCH 07/10] test preserved schedule start --- .../Feature/Schedule/ScheduleEndpointTest.php | 21 ++++++++++--------- .../UseCases/RescheduleScheduleTest.php | 4 ++-- .../website/cypress/e2e/set-scheduling.cy.ts | 10 ++++++--- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index a6595c4..7ee97e2 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -558,7 +558,7 @@ class ScheduleEndpointTest extends TestCase ->assertJsonCount(2, 'assignments'); } - public function test_it_reschedules_only_unfinished_assignments(): void + public function test_it_reschedules_only_unfinished_assignments_without_changing_start_date(): void { $user = $this->createUser('reader@example.com'); $set = $this->createSet($user, 'Course'); @@ -594,23 +594,24 @@ class ScheduleEndpointTest extends TestCase ]); $response->assertOk() - ->assertJsonPath('schedule.startDate', '2026-08-20') + ->assertJsonPath('schedule.startDate', '2026-08-10') ->assertJsonPath('schedule.targetDate', '2026-08-21') + ->assertJsonCount(12, 'schedule.days') ->assertJsonPath('schedule.days.0.date', '2026-08-10') ->assertJsonPath( 'schedule.days.0.assignments.0.completedAt', '2026-08-15T12:30:45+00:00', ) - ->assertJsonPath('schedule.days.1.date', '2026-08-20') - ->assertJsonCount(1, 'schedule.days.1.assignments') - ->assertJsonPath('schedule.days.1.assignments.0.id', 2) - ->assertJsonPath('schedule.days.2.date', '2026-08-21') - ->assertJsonCount(2, 'schedule.days.2.assignments') - ->assertJsonPath('schedule.days.2.assignments.0.id', 3) - ->assertJsonPath('schedule.days.2.assignments.1.id', 4); + ->assertJsonPath('schedule.days.10.date', '2026-08-20') + ->assertJsonCount(1, 'schedule.days.10.assignments') + ->assertJsonPath('schedule.days.10.assignments.0.id', 2) + ->assertJsonPath('schedule.days.11.date', '2026-08-21') + ->assertJsonCount(2, 'schedule.days.11.assignments') + ->assertJsonPath('schedule.days.11.assignments.0.id', 3) + ->assertJsonPath('schedule.days.11.assignments.1.id', 4); $this->assertDatabaseHas('schedules', [ 'id' => 1, - 'start_date' => '2026-08-20', + 'start_date' => '2026-08-10', 'target_date' => '2026-08-21', ]); $this->assertDatabaseHas('schedule_assignments', [ diff --git a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php index 3bc2b6b..cc2a671 100644 --- a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php @@ -19,7 +19,7 @@ use Tests\Fakes\FakeScheduleRepository; class RescheduleScheduleTest extends TestCase { - public function test_it_redistributes_only_unfinished_assignments(): void + public function test_it_redistributes_only_unfinished_assignments_without_changing_start_date(): void { $user = $this->user(1, 'reader@example.com'); $repository = new FakeScheduleRepository; @@ -42,7 +42,7 @@ class RescheduleScheduleTest extends TestCase ), ); - $this->assertSame('2026-08-20', $rescheduled->getStartDate() + $this->assertSame('2026-08-10', $rescheduled->getStartDate() ->format('Y-m-d')); $this->assertSame('2026-08-21', $rescheduled->getTargetDate() ->format('Y-m-d')); diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 36f52e0..936bfb8 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -249,10 +249,14 @@ const rescheduleSourceDetail = { const rescheduledDetail = { schedule: { ...rescheduleSourceDetail.schedule, - startDate: '2026-08-15', + startDate: '2026-08-10', targetDate: '2026-08-16', days: [ rescheduleSourceDetail.schedule.days[0], + { date: '2026-08-11', assignments: [] }, + { date: '2026-08-12', assignments: [] }, + { date: '2026-08-13', assignments: [] }, + { date: '2026-08-14', assignments: [] }, { date: '2026-08-15', assignments: [rescheduleSourceDetail.schedule.days[1]?.assignments[0]], @@ -599,8 +603,8 @@ describe('set scheduling', () => { 'have.text', '3 remaining assignments rescheduled.', ) - cy.contains('4 Chapter_sections-v2 assignments across 2 days').should('be.visible') - cy.get('.schedule-range').should('contain.text', 'Aug 15, 2026 to Aug 16, 2026') + cy.contains('4 Chapter_sections-v2 assignments across 7 days').should('be.visible') + cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 16, 2026') cy.get('[data-assignment-section="remaining"] [data-schedule-day]').then(($days) => { expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([ '2026-08-15', From d9d9869a49669423f1b438fd445528cde8f999e3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 22 Aug 2026 21:59:36 +0300 Subject: [PATCH 08/10] preserve schedule start date --- backend/app/Schedule/EloquentScheduleRepository.php | 1 - backend/app/Schedule/RescheduleScheduleDto.php | 1 - .../Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php | 1 - backend/tests/Fakes/FakeScheduleRepository.php | 2 +- 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 69f08a5..4a46683 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -52,7 +52,6 @@ class EloquentScheduleRepository implements ScheduleRepository ); } - $model->start_date = $dto->startDate->format('Y-m-d'); $model->target_date = $dto->targetDate->format('Y-m-d'); $model->save(); diff --git a/backend/app/Schedule/RescheduleScheduleDto.php b/backend/app/Schedule/RescheduleScheduleDto.php index 6b178a3..fe37da0 100644 --- a/backend/app/Schedule/RescheduleScheduleDto.php +++ b/backend/app/Schedule/RescheduleScheduleDto.php @@ -13,7 +13,6 @@ final readonly class RescheduleScheduleDto public function __construct( public int $scheduleId, public User $user, - public DateTimeImmutable $startDate, public DateTimeImmutable $targetDate, public array $assignments, ) {} diff --git a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php index 407a4c7..886181a 100644 --- a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php @@ -75,7 +75,6 @@ class RescheduleSchedule new RescheduleScheduleDto( scheduleId: $schedule->getId(), user: $request->user, - startDate: $startDate, targetDate: $targetDate, assignments: $assignmentDtos, ), diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index 2713d30..361deee 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -87,7 +87,7 @@ class FakeScheduleRepository implements ScheduleRepository user: $schedule->getUser(), setName: $schedule->getSetName(), elementKind: $schedule->getElementKind(), - startDate: $dto->startDate, + startDate: $schedule->getStartDate(), targetDate: $dto->targetDate, assignments: $assignments, ); From e5d4402f2c44849c9e69cdbef250832d3f3be256 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 22 Aug 2026 21:59:51 +0300 Subject: [PATCH 09/10] document reschedule date semantics --- ai/shared.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ai/shared.md b/ai/shared.md index eb2bd6b..d2872ea 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -20,8 +20,9 @@ these rules. - Persisted schedules must not reference their source set or selected level. Persisted assignments must not reference their source elements. Later source edits or deletions must not change an existing schedule. -- Schedule recalculation must preserve completed work while redistributing - unfinished assignments across the remaining dates. +- Schedule recalculation must preserve completed work and the schedule's + original start date. Treat the requested start as the boundary for + redistributing unfinished assignments, and update the target date. - Planned features in `README.md` are future ideas, not authorized scope. - The Laravel backend exists under `backend/`. - The standalone Vue frontend exists under `frontend/website/`. From 7ce8fdc4be09d5c88a00175d20223689ae409ff8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 22 Aug 2026 22:03:16 +0300 Subject: [PATCH 10/10] fix reschedule timeline expectation --- frontend/website/cypress/e2e/set-scheduling.cy.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 936bfb8..e0518a4 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -607,6 +607,10 @@ describe('set scheduling', () => { cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 16, 2026') cy.get('[data-assignment-section="remaining"] [data-schedule-day]').then(($days) => { expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([ + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', '2026-08-15', '2026-08-16', ])