Compare commits

...

11 commits

16 changed files with 1434 additions and 13 deletions

View file

@ -20,8 +20,9 @@ these rules.
- Persisted schedules must not reference their source set or selected level. - Persisted schedules must not reference their source set or selected level.
Persisted assignments must not reference their source elements. Later Persisted assignments must not reference their source elements. Later
source edits or deletions must not change an existing schedule. source edits or deletions must not change an existing schedule.
- Schedule recalculation must preserve completed work while redistributing - Schedule recalculation must preserve completed work and the schedule's
unfinished assignments across the remaining dates. 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. - Planned features in `README.md` are future ideas, not authorized scope.
- The Laravel backend exists under `backend/`. - The Laravel backend exists under `backend/`.
- The standalone Vue frontend exists under `frontend/website/`. - The standalone Vue frontend exists under `frontend/website/`.

View file

@ -11,9 +11,11 @@ use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
use App\Schedule\UseCases\GetSchedule\GetSchedule; use App\Schedule\UseCases\GetSchedule\GetSchedule;
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest; 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\SetAssignmentCompletion;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
use App\Shared\Http\RequestInput; use App\Shared\Http\RequestInput;
@ -27,6 +29,7 @@ class ScheduleController extends Controller
private CreateSchedule $createSchedule, private CreateSchedule $createSchedule,
private ListSchedules $listSchedules, private ListSchedules $listSchedules,
private GetSchedule $getSchedule, private GetSchedule $getSchedule,
private RescheduleSchedule $rescheduleSchedule,
private ListAssignmentsForDate $listAssignmentsForDate, private ListAssignmentsForDate $listAssignmentsForDate,
private SetAssignmentCompletion $setAssignmentCompletion, 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 public function assignments(Request $request): JsonResponse
{ {
$input = new RequestInput($request); $input = new RequestInput($request);
@ -206,15 +242,23 @@ class ScheduleController extends Controller
); );
} }
$days = []; $includedDates = [];
$date = $schedule->getStartDate(); $date = $schedule->getStartDate();
while ($date <= $schedule->getTargetDate()) { 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[] = [ $days[] = [
'date' => $formattedDate, 'date' => $formattedDate,
'assignments' => $assignmentsByDate[$formattedDate] ?? [], 'assignments' => $assignmentsByDate[$formattedDate] ?? [],
]; ];
$date = $date->modify('+1 day');
} }
return $days; return $days;

View file

@ -38,6 +38,38 @@ class EloquentScheduleRepository implements ScheduleRepository
}); });
} }
public function reschedule(RescheduleScheduleDto $dto): Schedule
{
return DB::transaction(function () use ($dto): Schedule {
$model = ScheduleModel::query()
->where('id', $dto->scheduleId)
->where('user_id', $dto->user->getId())
->lockForUpdate()
->first();
if ($model === null) {
throw new DomainException(
"Schedule with id {$dto->scheduleId} not found",
);
}
$model->target_date = $dto->targetDate->format('Y-m-d');
$model->save();
foreach ($dto->assignments as $assignmentDto) {
ScheduleAssignmentModel::query()
->where('id', $assignmentDto->id)
->where('schedule_id', $dto->scheduleId)
->whereNull('completed_at')
->update([
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
]);
}
return $this->toDomain($model, $dto->user);
});
}
public function findForUser(int $id, User $user): ?Schedule public function findForUser(int $id, User $user): ?Schedule
{ {
$model = ScheduleModel::query() $model = ScheduleModel::query()

View file

@ -0,0 +1,13 @@
<?php
namespace App\Schedule;
use DateTimeImmutable;
final readonly class RescheduleScheduleAssignmentDto
{
public function __construct(
public int $id,
public DateTimeImmutable $scheduledDate,
) {}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
final readonly class RescheduleScheduleDto
{
/**
* @param list<RescheduleScheduleAssignmentDto> $assignments
*/
public function __construct(
public int $scheduleId,
public User $user,
public DateTimeImmutable $targetDate,
public array $assignments,
) {}
}

View file

@ -9,6 +9,8 @@ interface ScheduleRepository
{ {
public function create(CreateScheduleDto $dto): Schedule; public function create(CreateScheduleDto $dto): Schedule;
public function reschedule(RescheduleScheduleDto $dto): Schedule;
public function findForUser(int $id, User $user): ?Schedule; public function findForUser(int $id, User $user): ?Schedule;
/** /**

View file

@ -0,0 +1,131 @@
<?php
namespace App\Schedule\UseCases\RescheduleSchedule;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\EvenDistributionScheduler;
use App\Schedule\RescheduleScheduleAssignmentDto;
use App\Schedule\RescheduleScheduleDto;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\Schedule\WorkloadPlacement;
use DateTimeImmutable;
use DateTimeZone;
class RescheduleSchedule
{
public function __construct(
private ScheduleRepository $scheduleRepository,
private EvenDistributionScheduler $evenDistributionScheduler,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(RescheduleScheduleRequest $request): Schedule
{
$schedule = $this->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;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Schedule\UseCases\RescheduleSchedule;
use App\User\User;
final readonly class RescheduleScheduleRequest
{
public function __construct(
public int $scheduleId,
public User $user,
public ?string $startDate,
public ?string $targetDate,
public ?string $workloadPlacement,
) {}
}

View file

@ -22,6 +22,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
)->whereNumber('assignmentId'); )->whereNumber('assignmentId');
Route::post('/schedules', [ScheduleController::class, 'store']); Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules', [ScheduleController::class, 'index']);
Route::patch(
'/schedules/{scheduleId}',
[ScheduleController::class, 'update'],
)->whereNumber('scheduleId');
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
->whereNumber('scheduleId'); ->whereNumber('scheduleId');
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);

View file

@ -2,8 +2,9 @@
namespace Tests\Fakes; namespace Tests\Fakes;
use App\Schedule\CreateScheduleDto;
use App\Schedule\AssignmentForDate; use App\Schedule\AssignmentForDate;
use App\Schedule\CreateScheduleDto;
use App\Schedule\RescheduleScheduleDto;
use App\Schedule\Schedule; use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository; use App\Schedule\ScheduleRepository;
@ -51,6 +52,50 @@ class FakeScheduleRepository implements ScheduleRepository
return $this->copy($schedule); 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 public function findForUser(int $id, User $user): ?Schedule
{ {
$schedule = $this->schedules[$id] ?? null; $schedule = $this->schedules[$id] ?? null;

View file

@ -558,6 +558,126 @@ class ScheduleEndpointTest extends TestCase
->assertJsonCount(2, 'assignments'); ->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 public function test_it_rejects_invalid_assignment_completion_input(): void
{ {
$user = $this->createUser('reader@example.com'); $user = $this->createUser('reader@example.com');
@ -718,6 +838,7 @@ class ScheduleEndpointTest extends TestCase
])->assertStatus(401); ])->assertStatus(401);
$this->getJson('/api/schedules')->assertStatus(401); $this->getJson('/api/schedules')->assertStatus(401);
$this->getJson('/api/schedules/1')->assertStatus(401); $this->getJson('/api/schedules/1')->assertStatus(401);
$this->patchJson('/api/schedules/1', [])->assertStatus(401);
$this->postJson('/api/schedules', [])->assertStatus(401); $this->postJson('/api/schedules', [])->assertStatus(401);
} }

View file

@ -0,0 +1,218 @@
<?php
namespace Tests\Unit\Schedule\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\EvenDistributionScheduler;
use App\Schedule\ScheduleAssignment;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleSchedule;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleScheduleRequest;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeScheduleRepository;
class RescheduleScheduleTest extends TestCase
{
public function test_it_redistributes_only_unfinished_assignments_without_changing_start_date(): void
{
$user = $this->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'));
}
}

View file

@ -184,6 +184,107 @@ const splitScheduleDetail = {
}, },
} }
const rescheduleSourceDetail = {
schedule: {
...scheduleDetail.schedule,
startDate: '2026-08-10',
targetDate: '2026-08-20',
assignmentCount: 4,
days: [
{
date: '2026-08-10',
assignments: [
{
...scheduleDetail.schedule.days[0]?.assignments[0],
completedAt: '2026-08-11T08:30:00+00:00',
},
],
},
{
date: '2026-08-18',
assignments: [
{
id: 2,
completedAt: null,
element: {
name: 'Chapter 2',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 2'],
},
},
],
},
{
date: '2026-08-19',
assignments: [
{
id: 3,
completedAt: null,
element: {
name: 'Chapter 3',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 3'],
},
},
],
},
{
date: '2026-08-20',
assignments: [
{
id: 4,
completedAt: null,
element: {
name: 'Chapter 4',
kind: 'Chapter_sections-v2',
path: ['Genesis', 'Creation', 'Chapter 4'],
},
},
],
},
],
},
}
const rescheduledDetail = {
schedule: {
...rescheduleSourceDetail.schedule,
startDate: '2026-08-10',
targetDate: '2026-08-16',
days: [
rescheduleSourceDetail.schedule.days[0],
{ date: '2026-08-11', assignments: [] },
{ date: '2026-08-12', assignments: [] },
{ date: '2026-08-13', assignments: [] },
{ date: '2026-08-14', assignments: [] },
{
date: '2026-08-15',
assignments: [rescheduleSourceDetail.schedule.days[1]?.assignments[0]],
},
{
date: '2026-08-16',
assignments: [
rescheduleSourceDetail.schedule.days[2]?.assignments[0],
rescheduleSourceDetail.schedule.days[3]?.assignments[0],
],
},
],
},
}
const fullyCompletedScheduleDetail = {
schedule: {
...scheduleDetail.schedule,
days: scheduleDetail.schedule.days.map((day) => ({
...day,
assignments: day.assignments.map((assignment) => ({
...assignment,
completedAt: '2026-08-15T09:30:00+00:00',
})),
})),
},
}
function interceptAuthenticatedUser(): void { function interceptAuthenticatedUser(): void {
cy.intercept('GET', '**/api/me', { cy.intercept('GET', '**/api/me', {
statusCode: 200, statusCode: 200,
@ -458,6 +559,134 @@ describe('set scheduling', () => {
}) })
}) })
it('reschedules unfinished assignments while preserving completed work', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: rescheduleSourceDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73', (request) => {
expect(request.headers.accept).to.equal('application/json')
expect(request.body).to.deep.equal({
startDate: '2026-08-15',
targetDate: '2026-08-16',
workloadPlacement: 'end',
})
request.reply({ statusCode: 200, body: rescheduledDetail })
}).as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-form]').should('not.exist')
cy.get('[data-reschedule-toggle]').should('have.text', 'Reschedule remaining').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-20')
cy.get('[data-workload-placement]').should('not.exist')
cy.get('#reschedule-target-date').clear().type('2026-08-16')
cy.get('[data-workload-placement]').should('be.visible').within(() => {
cy.get('input[value="middle"]').should('be.checked')
cy.get('input[value="end"]').check()
})
cy.viewport(375, 667)
cy.document().then((document) => {
expect(document.documentElement.scrollWidth).to.be.at.most(
document.documentElement.clientWidth,
)
})
cy.get('[data-reschedule-form]').submit()
cy.wait('@reschedule')
cy.get('[data-reschedule-form]').should('not.exist')
cy.get('[data-reschedule-announcement]').should(
'have.text',
'3 remaining assignments rescheduled.',
)
cy.contains('4 Chapter_sections-v2 assignments across 7 days').should('be.visible')
cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 16, 2026')
cy.get('[data-assignment-section="remaining"] [data-schedule-day]').then(($days) => {
expect([...$days].map((day) => day.getAttribute('data-schedule-date'))).to.deep.equal([
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
'2026-08-16',
])
})
cy.get('[data-assignment-section="completed"] summary').click()
cy.get('[data-assignment-section="completed"] [data-schedule-day]')
.should('have.length', 1)
.and('have.attr', 'data-schedule-date', '2026-08-10')
cy.get('[data-reschedule-toggle]').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-16')
})
it('uses today for an expired target and validates before rescheduling', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: scheduleDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73').as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').click()
cy.get('#reschedule-start-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').should('have.value', '2026-08-15')
cy.get('#reschedule-target-date').clear().type('2026-08-14')
cy.get('[data-reschedule-form]').submit()
cy.get('#reschedule-target-date-error').should(
'have.text',
'Target date cannot be before the start date.',
)
cy.get('@reschedule.all').should('have.length', 0)
})
it('does not offer rescheduling after all assignments are complete', () => {
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: fullyCompletedScheduleDetail,
}).as('schedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').should('not.exist')
})
it('keeps the current plan visible when rescheduling fails', () => {
cy.clock(new Date(2026, 7, 15, 12).getTime(), ['Date'])
cy.intercept('GET', '**/api/schedules/73', {
statusCode: 200,
body: rescheduleSourceDetail,
}).as('schedule')
cy.intercept('PATCH', '**/api/schedules/73', {
statusCode: 500,
body: { error: 'Rescheduling is temporarily unavailable.' },
}).as('reschedule')
cy.visit('/schedules/73')
cy.wait('@me')
cy.wait('@schedule')
cy.get('[data-reschedule-toggle]').click()
cy.get('[data-reschedule-form]').submit()
cy.wait('@reschedule')
cy.get('[data-reschedule-form]')
.should('be.visible')
.and('contain.text', 'Rescheduling is temporarily unavailable.')
cy.get('.schedule-range').should('contain.text', 'Aug 10, 2026 to Aug 20, 2026')
cy.get('[data-reschedule-announcement]').should('have.text', '')
})
it('completes and reopens assignments from the schedule', () => { it('completes and reopens assignments from the schedule', () => {
const completedAt = '2026-08-15T12:30:00+00:00' const completedAt = '2026-08-15T12:30:00+00:00'
cy.intercept('GET', '**/api/schedules/73', { cy.intercept('GET', '**/api/schedules/73', {

View file

@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { z } from 'zod'
import {
workloadPlacementSchema,
type RescheduleScheduleInput,
type WorkloadPlacement,
} from '@/stores/schedules'
type RescheduleField = 'startDate' | 'targetDate'
type RescheduleFieldErrors = Partial<Record<RescheduleField, string>>
const props = defineProps<{
currentTargetDate: string
remainingAssignmentCount: number
serverError: string | null
submitting: boolean
today: string
}>()
const emit = defineEmits<{
cancel: []
submit: [input: RescheduleScheduleInput]
}>()
const form = reactive({
startDate: props.today,
targetDate: props.currentTargetDate < props.today ? props.today : props.currentTargetDate,
workloadPlacement: 'middle' as WorkloadPlacement,
})
const fieldErrors = ref<RescheduleFieldErrors>({})
const workloadPlacementOptions: Array<{
value: WorkloadPlacement
label: string
}> = [
{ value: 'start', label: 'At the start' },
{ value: 'middle', label: 'In the middle' },
{ value: 'end', label: 'At the end' },
]
const formSchema = z
.object({
startDate: z.string().min(1, 'Choose a start date.'),
targetDate: z.string().min(1, 'Choose a target date.'),
workloadPlacement: workloadPlacementSchema,
})
.superRefine((value, context) => {
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
context.addIssue({
code: 'custom',
path: ['targetDate'],
message: 'Target date cannot be before the start date.',
})
}
})
const showWorkloadPlacement = computed(() => {
if (form.startDate === '' || form.targetDate === '' || form.targetDate < form.startDate) {
return false
}
const dayCount = inclusiveDayCount(form.startDate, form.targetDate)
return (
dayCount > 0 &&
props.remainingAssignmentCount > dayCount &&
props.remainingAssignmentCount % dayCount !== 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 submit(): void {
fieldErrors.value = {}
const result = formSchema.safeParse(form)
if (!result.success) {
const errors: RescheduleFieldErrors = {}
for (const issue of result.error.issues) {
const field = issue.path[0]
if ((field === 'startDate' || field === 'targetDate') && errors[field] === undefined) {
errors[field] = issue.message
}
}
fieldErrors.value = errors
return
}
emit('submit', result.data)
}
</script>
<template>
<section id="reschedule-panel" class="reschedule-panel" aria-labelledby="reschedule-heading">
<header>
<p class="reschedule-panel__eyebrow">Adjust your plan</p>
<h2 id="reschedule-heading">Reschedule remaining assignments</h2>
<p>
Completed work stays on its original date. Only the
{{ remainingAssignmentCount }} unfinished
{{ remainingAssignmentCount === 1 ? 'assignment' : 'assignments' }}
will move.
</p>
</header>
<form data-reschedule-form novalidate @submit.prevent="submit">
<div class="reschedule-date-fields">
<div class="reschedule-field">
<label for="reschedule-start-date">Start date</label>
<input
id="reschedule-start-date"
v-model="form.startDate"
type="date"
:aria-invalid="fieldErrors.startDate !== undefined"
:aria-describedby="fieldErrors.startDate ? 'reschedule-start-date-error' : undefined"
:disabled="submitting"
/>
<p v-if="fieldErrors.startDate" id="reschedule-start-date-error" class="field-error">
{{ fieldErrors.startDate }}
</p>
</div>
<div class="reschedule-field">
<label for="reschedule-target-date">Target date</label>
<input
id="reschedule-target-date"
v-model="form.targetDate"
type="date"
:aria-invalid="fieldErrors.targetDate !== undefined"
:aria-describedby="fieldErrors.targetDate ? 'reschedule-target-date-error' : undefined"
:disabled="submitting"
/>
<p v-if="fieldErrors.targetDate" id="reschedule-target-date-error" class="field-error">
{{ fieldErrors.targetDate }}
</p>
</div>
</div>
<fieldset
v-if="showWorkloadPlacement"
class="workload-placement"
data-workload-placement
:disabled="submitting"
>
<legend>Heavier days</legend>
<p>Some days need one extra assignment. Choose where those days appear in the schedule.</p>
<div class="workload-placement__options">
<label
v-for="option in workloadPlacementOptions"
:key="option.value"
class="workload-placement__option"
>
<input
v-model="form.workloadPlacement"
type="radio"
name="rescheduleWorkloadPlacement"
:value="option.value"
/>
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
<p v-if="serverError !== null" class="form-error" role="alert">
{{ serverError }}
</p>
<div class="reschedule-actions">
<button type="submit" class="primary-button" :disabled="submitting">
{{ submitting ? 'Rescheduling...' : 'Reschedule assignments' }}
</button>
<button
type="button"
class="secondary-button"
:disabled="submitting"
@click="emit('cancel')"
>
Cancel
</button>
</div>
</form>
</section>
</template>
<style scoped>
.reschedule-panel {
display: grid;
gap: 1.5rem;
margin-top: 1.5rem;
padding: clamp(1.3rem, 4vw, 2rem);
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 1rem;
background: rgb(255 253 247 / 82%);
box-shadow: 0 1rem 2.5rem rgb(40 62 52 / 8%);
}
.reschedule-panel__eyebrow {
margin: 0 0 0.45rem;
color: #926044;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.reschedule-panel h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 500;
letter-spacing: -0.035em;
}
.reschedule-panel header > p:last-child {
max-width: 44rem;
margin: 0.75rem 0 0;
color: #68776f;
line-height: 1.55;
}
.reschedule-panel form {
display: grid;
gap: 1.35rem;
}
.reschedule-date-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.reschedule-field {
display: grid;
gap: 0.55rem;
}
.reschedule-field label,
.workload-placement legend {
color: #344e45;
font-size: 0.78rem;
font-weight: 800;
}
.reschedule-field input {
min-width: 0;
min-height: 2.8rem;
padding: 0.55rem 0.7rem;
border: 1px solid rgb(24 48 41 / 22%);
border-radius: 0.65rem;
color: #183029;
background: #fffdf7;
font: inherit;
}
.field-error,
.form-error {
margin: 0;
color: #9b3f32;
font-size: 0.78rem;
font-weight: 700;
}
.workload-placement {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 1rem;
border: 1px solid rgb(24 48 41 / 12%);
border-radius: 0.8rem;
}
.workload-placement > p {
margin: 0;
color: #68776f;
font-size: 0.8rem;
line-height: 1.5;
}
.workload-placement__options {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.workload-placement__option {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.7rem;
border: 1px solid rgb(24 48 41 / 16%);
border-radius: 999px;
color: #344e45;
background: #f9f5e9;
font-size: 0.78rem;
font-weight: 750;
cursor: pointer;
}
.reschedule-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.primary-button,
.secondary-button {
min-height: 2.75rem;
padding: 0.65rem 1rem;
border-radius: 0.7rem;
font-size: 0.82rem;
font-weight: 800;
cursor: pointer;
}
.primary-button {
border: 1px solid #285c4e;
color: #fffdf7;
background: #285c4e;
}
.secondary-button {
border: 1px solid rgb(24 58 49 / 28%);
color: #183a31;
background: transparent;
}
.reschedule-field input:focus-visible,
.workload-placement__option input:focus-visible,
.primary-button:focus-visible,
.secondary-button:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: wait;
opacity: 0.65;
}
@media (max-width: 37.5rem) {
.reschedule-date-fields {
grid-template-columns: 1fr;
}
.reschedule-actions {
align-items: stretch;
flex-direction: column;
}
}
</style>

View file

@ -86,10 +86,16 @@ export type CreateScheduleInput = {
targetDate: string targetDate: string
workloadPlacement: WorkloadPlacement workloadPlacement: WorkloadPlacement
} }
export type RescheduleScheduleInput = {
startDate: string
targetDate: string
workloadPlacement: WorkloadPlacement
}
const LIST_ERROR = "We couldn't load your schedules." const LIST_ERROR = "We couldn't load your schedules."
const DETAIL_ERROR = "We couldn't load this schedule." const DETAIL_ERROR = "We couldn't load this schedule."
const CREATE_ERROR = "We couldn't create 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 ASSIGNMENTS_ERROR = "We couldn't load today's assignments."
const COMPLETION_ERROR = "We couldn't update this assignment." const COMPLETION_ERROR = "We couldn't update this assignment."
@ -103,6 +109,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
const detailNotFound = ref(false) const detailNotFound = ref(false)
const creating = ref(false) const creating = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const rescheduling = ref(false)
const rescheduleError = ref<string | null>(null)
const assignmentsForDate = ref<AssignmentForDate[]>([]) const assignmentsForDate = ref<AssignmentForDate[]>([])
const assignmentsLoading = ref(false) const assignmentsLoading = ref(false)
const assignmentsError = ref<string | null>(null) const assignmentsError = ref<string | null>(null)
@ -241,6 +249,56 @@ export const useSchedulesStore = defineStore('schedules', () => {
} }
} }
async function rescheduleSchedule(
scheduleId: number,
input: RescheduleScheduleInput,
): Promise<ScheduleDetail | null> {
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<boolean> { async function fetchAssignmentsForDate(date: string): Promise<boolean> {
const requestId = ++assignmentsRequestId const requestId = ++assignmentsRequestId
assignmentsForDate.value = [] assignmentsForDate.value = []
@ -381,6 +439,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
detailNotFound, detailNotFound,
creating, creating,
createError, createError,
rescheduling,
rescheduleError,
assignmentsForDate, assignmentsForDate,
assignmentsLoading, assignmentsLoading,
assignmentsError, assignmentsError,
@ -389,6 +449,8 @@ export const useSchedulesStore = defineStore('schedules', () => {
fetchSchedules, fetchSchedules,
fetchSchedule, fetchSchedule,
createSchedule, createSchedule,
rescheduleSchedule,
clearRescheduleError,
fetchAssignmentsForDate, fetchAssignmentsForDate,
setAssignmentCompleted, setAssignmentCompleted,
isAssignmentCompletionPending, isAssignmentCompletionPending,

View file

@ -5,7 +5,12 @@ import { useRoute } from 'vue-router'
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
import ScheduleAssignmentTimeline from '@/components/ScheduleAssignmentTimeline.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] type ScheduleDay = ScheduleDetail['days'][number]
@ -18,9 +23,13 @@ const {
detailLoading, detailLoading,
detailError, detailError,
detailNotFound, detailNotFound,
rescheduling,
rescheduleError,
} = storeToRefs(schedulesStore) } = storeToRefs(schedulesStore)
const currentScheduleId = ref<number | null>(null) const currentScheduleId = ref<number | null>(null)
const completionAnnouncement = ref('') const completionAnnouncement = ref('')
const rescheduleAnnouncement = ref('')
const rescheduleFormOpen = ref(false)
const todayDate = browserDate(new Date()) const todayDate = browserDate(new Date())
const remainingDays = computed<ScheduleDay[]>(() => { const remainingDays = computed<ScheduleDay[]>(() => {
@ -28,9 +37,12 @@ const remainingDays = computed<ScheduleDay[]>(() => {
return [] 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 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 }] : [] return assignments.length > 0 || isRestDay ? [{ date: day.date, assignments }] : []
}) })
@ -50,6 +62,13 @@ const completedDays = computed<ScheduleDay[]>(() => {
const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value)) const remainingAssignmentCount = computed(() => assignmentCount(remainingDays.value))
const completedAssignmentCount = computed(() => assignmentCount(completedDays.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( watch(
() => route.params.scheduleId, () => route.params.scheduleId,
@ -59,6 +78,9 @@ watch(
: scheduleIdParameter : scheduleIdParameter
const scheduleId = Number(rawScheduleId) const scheduleId = Number(rawScheduleId)
currentScheduleId.value = scheduleId currentScheduleId.value = scheduleId
rescheduleFormOpen.value = false
rescheduleAnnouncement.value = ''
schedulesStore.clearRescheduleError()
if (activeSchedule.value?.id !== scheduleId) { if (activeSchedule.value?.id !== scheduleId) {
await schedulesStore.fetchSchedule(scheduleId) await schedulesStore.fetchSchedule(scheduleId)
@ -92,6 +114,48 @@ function assignmentCount(days: ScheduleDay[]): number {
return days.reduce((count, day) => count + day.assignments.length, 0) 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<void> {
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<void> { async function setAssignmentCompleted(assignmentId: number, completed: boolean): Promise<void> {
completionAnnouncement.value = '' completionAnnouncement.value = ''
const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed) const updated = await schedulesStore.setAssignmentCompleted(assignmentId, completed)
@ -136,15 +200,47 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
{{ activeSchedule.assignmentCount }} {{ activeSchedule.assignmentCount }}
{{ activeSchedule.elementKind }} {{ activeSchedule.elementKind }}
{{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across {{ activeSchedule.assignmentCount === 1 ? 'assignment' : 'assignments' }} across
{{ activeSchedule.days.length }} {{ activeDayCount }}
{{ activeSchedule.days.length === 1 ? 'day' : 'days' }} {{ activeDayCount === 1 ? 'day' : 'days' }}
</p> </p>
<p class="schedule-range"> <p class="schedule-range">
{{ formatDate(activeSchedule.startDate) }} to {{ formatDate(activeSchedule.startDate) }} to
{{ formatDate(activeSchedule.targetDate) }} {{ formatDate(activeSchedule.targetDate) }}
</p> </p>
<button
v-if="remainingAssignmentCount > 0"
type="button"
class="reschedule-toggle"
data-reschedule-toggle
:aria-expanded="rescheduleFormOpen"
aria-controls="reschedule-panel"
:disabled="rescheduling"
@click="rescheduleFormOpen ? cancelReschedule() : openRescheduleForm()"
>
{{ rescheduleFormOpen ? 'Close rescheduling' : 'Reschedule remaining' }}
</button>
</header> </header>
<p
class="reschedule-announcement"
role="status"
aria-live="polite"
data-reschedule-announcement
>
{{ rescheduleAnnouncement }}
</p>
<ScheduleRescheduleForm
v-if="rescheduleFormOpen"
:current-target-date="activeSchedule.targetDate"
:remaining-assignment-count="remainingAssignmentCount"
:server-error="rescheduleError"
:submitting="rescheduling"
:today="todayDate"
@cancel="cancelReschedule"
@submit="submitReschedule"
/>
<p <p
class="completion-announcement" class="completion-announcement"
role="status" role="status"
@ -252,7 +348,8 @@ async function setAssignmentCompleted(assignmentId: number, completed: boolean):
} }
.back-link:focus-visible, .back-link:focus-visible,
.retry-button:focus-visible { .retry-button:focus-visible,
.reschedule-toggle:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%); outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem; outline-offset: 0.25rem;
} }
@ -294,6 +391,35 @@ h1 {
color: #68776f; color: #68776f;
} }
.reschedule-toggle {
min-height: 2.65rem;
margin-top: 1.25rem;
padding: 0.65rem 1rem;
border: 1px solid rgb(24 58 49 / 28%);
border-radius: 0.7rem;
color: #183a31;
background: #fffdf7;
font-size: 0.82rem;
font-weight: 750;
cursor: pointer;
}
.reschedule-toggle:disabled {
cursor: wait;
opacity: 0.65;
}
.reschedule-announcement:empty {
display: none;
}
.reschedule-announcement {
margin: 1rem 0 0;
color: #285c4e;
font-size: 0.85rem;
font-weight: 750;
}
.completion-announcement { .completion-announcement {
position: absolute; position: absolute;
width: 1px; width: 1px;