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 @@
-
-
-
- Adjust your plan
- Completed work stays on its original date. Only the
- {{ remainingAssignmentCount }} unfinished
- {{ remainingAssignmentCount === 1 ? 'assignment' : 'assignments' }}
- will move.
- Reschedule remaining assignments
-
{{ formatDate(activeSchedule.startDate) }} to {{ formatDate(activeSchedule.targetDate) }}
- -- {{ rescheduleAnnouncement }} -
- -