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/`. 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..4a46683 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -38,6 +38,38 @@ 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->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 $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..886181a --- /dev/null +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php @@ -0,0 +1,131 @@ +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, + 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..361deee 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: $schedule->getStartDate(), + 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; diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index ef98f8c..7ee97e2 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -558,6 +558,126 @@ class ScheduleEndpointTest extends TestCase ->assertJsonCount(2, 'assignments'); } + public function test_it_reschedules_only_unfinished_assignments_without_changing_start_date(): 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-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.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-10', + '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 +838,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..cc2a671 --- /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-10', $rescheduled->getStartDate() + ->format('Y-m-d')); + $this->assertSame('2026-08-21', $rescheduled->getTargetDate() + ->format('Y-m-d')); + $this->assertSame( + [ + '2026-08-20', + '2026-08-12', + '2026-08-21', + '2026-08-14', + '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')); + } +} diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index b799db0..e0518a4 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -184,6 +184,107 @@ 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-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]], + }, + { + 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 +559,134 @@ 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.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') + + 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 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-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '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('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', { 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 }} +

+ + +