Compare commits
11 commits
a06bb6f003
...
d2002eccb9
| Author | SHA1 | Date | |
|---|---|---|---|
| d2002eccb9 | |||
| 7ce8fdc4be | |||
| e5d4402f2c | |||
| d9d9869a49 | |||
| bcbc2e6ba3 | |||
| db6b41ee42 | |||
| 3a34249e0e | |||
| 6ab6ed801a | |||
| 7576ccaf09 | |||
| b8eeb093d3 | |||
| 258e4ea27a |
16 changed files with 1434 additions and 13 deletions
|
|
@ -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/`.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
13
backend/app/Schedule/RescheduleScheduleAssignmentDto.php
Normal file
13
backend/app/Schedule/RescheduleScheduleAssignmentDto.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class RescheduleScheduleAssignmentDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public DateTimeImmutable $scheduledDate,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
backend/app/Schedule/RescheduleScheduleDto.php
Normal file
19
backend/app/Schedule/RescheduleScheduleDto.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
218
backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php
Normal file
218
backend/tests/Unit/Schedule/UseCases/RescheduleScheduleTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
358
frontend/website/src/components/ScheduleRescheduleForm.vue
Normal file
358
frontend/website/src/components/ScheduleRescheduleForm.vue
Normal 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>
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue