diff --git a/ai/shared.md b/ai/shared.md index d2872ea..eb2bd6b 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -20,9 +20,8 @@ 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 and the schedule's - original start date. Treat the requested start as the boundary for - redistributing unfinished assignments, and update the target date. +- Schedule recalculation must preserve completed work while redistributing + unfinished assignments across the remaining dates. - 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 d37266f..c1f8d12 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -11,11 +11,9 @@ 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; @@ -29,7 +27,6 @@ class ScheduleController extends Controller private CreateSchedule $createSchedule, private ListSchedules $listSchedules, private GetSchedule $getSchedule, - private RescheduleSchedule $rescheduleSchedule, private ListAssignmentsForDate $listAssignmentsForDate, private SetAssignmentCompletion $setAssignmentCompletion, ) {} @@ -103,39 +100,6 @@ 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); @@ -242,23 +206,15 @@ class ScheduleController extends Controller ); } - $includedDates = []; + $days = []; $date = $schedule->getStartDate(); while ($date <= $schedule->getTargetDate()) { - $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) { + $formattedDate = $date->format('Y-m-d'); $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 4a46683..8576591 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -38,38 +38,6 @@ 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 deleted file mode 100644 index 510a127..0000000 --- a/backend/app/Schedule/RescheduleScheduleAssignmentDto.php +++ /dev/null @@ -1,13 +0,0 @@ - $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 d422a68..20b5f9f 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -9,8 +9,6 @@ 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 deleted file mode 100644 index 886181a..0000000 --- a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index d23468e..0000000 --- a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php +++ /dev/null @@ -1,16 +0,0 @@ -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 361deee..e12d126 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -2,9 +2,8 @@ namespace Tests\Fakes; -use App\Schedule\AssignmentForDate; use App\Schedule\CreateScheduleDto; -use App\Schedule\RescheduleScheduleDto; +use App\Schedule\AssignmentForDate; use App\Schedule\Schedule; use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; @@ -52,50 +51,6 @@ 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 7ee97e2..ef98f8c 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -558,126 +558,6 @@ 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'); @@ -838,7 +718,6 @@ 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 deleted file mode 100644 index cc2a671..0000000 --- a/backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php +++ /dev/null @@ -1,218 +0,0 @@ -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 e0518a4..b799db0 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -184,107 +184,6 @@ 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, @@ -559,134 +458,6 @@ 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 deleted file mode 100644 index e61e166..0000000 --- a/frontend/website/src/components/ScheduleRescheduleForm.vue +++ /dev/null @@ -1,358 +0,0 @@ - - - - - diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 75d3c63..c6c3fde 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -86,16 +86,10 @@ 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." @@ -109,8 +103,6 @@ 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) @@ -249,56 +241,6 @@ 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 = [] @@ -439,8 +381,6 @@ export const useSchedulesStore = defineStore('schedules', () => { detailNotFound, creating, createError, - rescheduling, - rescheduleError, assignmentsForDate, assignmentsLoading, assignmentsError, @@ -449,8 +389,6 @@ 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 bf700ac..5c72243 100644 --- a/frontend/website/src/views/ScheduleDetailView.vue +++ b/frontend/website/src/views/ScheduleDetailView.vue @@ -5,12 +5,7 @@ import { useRoute } from 'vue-router' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.vue' -import ScheduleRescheduleForm from '@/components/ScheduleRescheduleForm.vue' -import { - useSchedulesStore, - type RescheduleScheduleInput, - type ScheduleDetail, -} from '@/stores/schedules' +import { useSchedulesStore, type ScheduleDetail } from '@/stores/schedules' type ScheduleDay = ScheduleDetail['days'][number] @@ -23,13 +18,9 @@ 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(() => { @@ -37,12 +28,9 @@ const remainingDays = computed(() => { return [] } - const schedule = activeSchedule.value - - return schedule.days.flatMap((day) => { + return activeSchedule.value.days.flatMap((day) => { const assignments = day.assignments.filter((assignment) => assignment.completedAt === null) - const isActiveDate = day.date >= schedule.startDate && day.date <= schedule.targetDate - const isRestDay = isActiveDate && day.assignments.length === 0 + const isRestDay = day.assignments.length === 0 return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : [] }) @@ -62,13 +50,6 @@ 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, @@ -78,9 +59,6 @@ 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) @@ -114,48 +92,6 @@ 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) @@ -200,47 +136,15 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean): {{ activeSchedule.assignmentCount }} {{ activeSchedule.elementKind }} {{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across - {{ activeDayCount }} - {{ activeDayCount === 1 ? 'day' : 'days' }} + {{ activeSchedule.days.length }} + {{ activeSchedule.days.length === 1 ? 'day' : 'days' }}

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

- -

- {{ rescheduleAnnouncement }} -

- - -