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