Compare commits
3 commits
80f8031ecc
...
6c8250b3a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c8250b3a4 | |||
| 46ba265f38 | |||
| 465db4a5b8 |
12 changed files with 636 additions and 34 deletions
|
|
@ -38,7 +38,10 @@ When creating a schedule, the user selects:
|
||||||
* The start date
|
* The start date
|
||||||
* The target completion date
|
* The target completion date
|
||||||
|
|
||||||
Attainly then distributes the selected elements evenly across the available days.
|
Attainly then distributes the selected elements evenly across the available
|
||||||
|
days. When an uneven schedule has more than one assignment per day, the user
|
||||||
|
can place the consecutive heavier days at the start, middle, or end of the
|
||||||
|
schedule.
|
||||||
|
|
||||||
For example, a user could choose to schedule:
|
For example, a user could choose to schedule:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ class ScheduleController extends Controller
|
||||||
levelId: $input->integer('levelId'),
|
levelId: $input->integer('levelId'),
|
||||||
startDate: $input->string('startDate'),
|
startDate: $input->string('startDate'),
|
||||||
targetDate: $input->string('targetDate'),
|
targetDate: $input->string('targetDate'),
|
||||||
|
workloadPlacement: $input->string(
|
||||||
|
'workloadPlacement',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (BadRequestException $exception) {
|
} catch (BadRequestException $exception) {
|
||||||
|
|
|
||||||
114
backend/app/Schedule/EvenDistributionScheduler.php
Normal file
114
backend/app/Schedule/EvenDistributionScheduler.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
class EvenDistributionScheduler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<DateTimeImmutable>
|
||||||
|
*/
|
||||||
|
public function scheduledDates(
|
||||||
|
int $assignmentCount,
|
||||||
|
DateTimeImmutable $startDate,
|
||||||
|
DateTimeImmutable $targetDate,
|
||||||
|
WorkloadPlacement $workloadPlacement,
|
||||||
|
): array {
|
||||||
|
$differenceInDays = $startDate->diff($targetDate)->days;
|
||||||
|
$dayCount = $differenceInDays + 1;
|
||||||
|
|
||||||
|
if ($assignmentCount < $dayCount) {
|
||||||
|
return $this->sparseDates(
|
||||||
|
assignmentCount: $assignmentCount,
|
||||||
|
dayCount: $dayCount,
|
||||||
|
startDate: $startDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->dailyDates(
|
||||||
|
assignmentCount: $assignmentCount,
|
||||||
|
dayCount: $dayCount,
|
||||||
|
startDate: $startDate,
|
||||||
|
workloadPlacement: $workloadPlacement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<DateTimeImmutable>
|
||||||
|
*/
|
||||||
|
private function sparseDates(
|
||||||
|
int $assignmentCount,
|
||||||
|
int $dayCount,
|
||||||
|
DateTimeImmutable $startDate,
|
||||||
|
): array {
|
||||||
|
if ($assignmentCount === 1) {
|
||||||
|
return [$startDate];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dates = [];
|
||||||
|
for ($assignmentIndex = 0;
|
||||||
|
$assignmentIndex < $assignmentCount;
|
||||||
|
$assignmentIndex++
|
||||||
|
) {
|
||||||
|
$scaledIndex = $assignmentIndex * ($dayCount - 1)
|
||||||
|
/ ($assignmentCount - 1);
|
||||||
|
$dayIndex = (int) floor($scaledIndex + 0.5);
|
||||||
|
$dates[] = $startDate->modify("+{$dayIndex} days");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<DateTimeImmutable>
|
||||||
|
*/
|
||||||
|
private function dailyDates(
|
||||||
|
int $assignmentCount,
|
||||||
|
int $dayCount,
|
||||||
|
DateTimeImmutable $startDate,
|
||||||
|
WorkloadPlacement $workloadPlacement,
|
||||||
|
): array {
|
||||||
|
$assignmentsPerDay = intdiv($assignmentCount, $dayCount);
|
||||||
|
$heavierDayCount = $assignmentCount % $dayCount;
|
||||||
|
$heavierBlockStart = $this->heavierBlockStart(
|
||||||
|
dayCount: $dayCount,
|
||||||
|
heavierDayCount: $heavierDayCount,
|
||||||
|
workloadPlacement: $workloadPlacement,
|
||||||
|
);
|
||||||
|
$dates = [];
|
||||||
|
|
||||||
|
for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) {
|
||||||
|
$assignmentCountForDay = $assignmentsPerDay;
|
||||||
|
if (
|
||||||
|
$dayIndex >= $heavierBlockStart
|
||||||
|
&& $dayIndex < $heavierBlockStart + $heavierDayCount
|
||||||
|
) {
|
||||||
|
$assignmentCountForDay++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $startDate->modify("+{$dayIndex} days");
|
||||||
|
for ($index = 0; $index < $assignmentCountForDay; $index++) {
|
||||||
|
$dates[] = $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function heavierBlockStart(
|
||||||
|
int $dayCount,
|
||||||
|
int $heavierDayCount,
|
||||||
|
WorkloadPlacement $workloadPlacement,
|
||||||
|
): int {
|
||||||
|
if ($workloadPlacement === WorkloadPlacement::Start) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workloadPlacement === WorkloadPlacement::End) {
|
||||||
|
return $dayCount - $heavierDayCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return intdiv($dayCount - $heavierDayCount, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,10 @@ use App\Exceptions\BadRequestException;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
use App\Schedule\CreateScheduleAssignmentDto;
|
use App\Schedule\CreateScheduleAssignmentDto;
|
||||||
use App\Schedule\CreateScheduleDto;
|
use App\Schedule\CreateScheduleDto;
|
||||||
|
use App\Schedule\EvenDistributionScheduler;
|
||||||
use App\Schedule\Schedule;
|
use App\Schedule\Schedule;
|
||||||
use App\Schedule\ScheduleRepository;
|
use App\Schedule\ScheduleRepository;
|
||||||
|
use App\Schedule\WorkloadPlacement;
|
||||||
use App\Set\Set;
|
use App\Set\Set;
|
||||||
use App\Set\SetLevelRepository;
|
use App\Set\SetLevelRepository;
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
|
|
@ -23,6 +25,7 @@ class CreateSchedule
|
||||||
private SetLevelRepository $setLevelRepository,
|
private SetLevelRepository $setLevelRepository,
|
||||||
private ElementRepository $elementRepository,
|
private ElementRepository $elementRepository,
|
||||||
private ScheduleRepository $scheduleRepository,
|
private ScheduleRepository $scheduleRepository,
|
||||||
|
private EvenDistributionScheduler $evenDistributionScheduler,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -61,6 +64,9 @@ class CreateSchedule
|
||||||
'targetDate must not be before startDate',
|
'targetDate must not be before startDate',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
$workloadPlacement = $this->workloadPlacement(
|
||||||
|
$request->workloadPlacement,
|
||||||
|
);
|
||||||
|
|
||||||
$elements = array_values(array_filter(
|
$elements = array_values(array_filter(
|
||||||
$this->orderedElements($set),
|
$this->orderedElements($set),
|
||||||
|
|
@ -72,10 +78,15 @@ class CreateSchedule
|
||||||
throw new BadRequestException('level has no elements');
|
throw new BadRequestException('level has no elements');
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignments = $this->assignments(
|
$scheduledDates = $this->evenDistributionScheduler->scheduledDates(
|
||||||
elements: $elements,
|
assignmentCount: count($elements),
|
||||||
startDate: $startDate,
|
startDate: $startDate,
|
||||||
targetDate: $targetDate,
|
targetDate: $targetDate,
|
||||||
|
workloadPlacement: $workloadPlacement,
|
||||||
|
);
|
||||||
|
$assignments = $this->assignments(
|
||||||
|
elements: $elements,
|
||||||
|
scheduledDates: $scheduledDates,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->scheduleRepository->create(new CreateScheduleDto(
|
return $this->scheduleRepository->create(new CreateScheduleDto(
|
||||||
|
|
@ -144,29 +155,21 @@ class CreateSchedule
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Element> $elements
|
* @param list<Element> $elements
|
||||||
|
* @param list<DateTimeImmutable> $scheduledDates
|
||||||
* @return list<CreateScheduleAssignmentDto>
|
* @return list<CreateScheduleAssignmentDto>
|
||||||
*/
|
*/
|
||||||
private function assignments(
|
private function assignments(
|
||||||
array $elements,
|
array $elements,
|
||||||
DateTimeImmutable $startDate,
|
array $scheduledDates,
|
||||||
DateTimeImmutable $targetDate,
|
|
||||||
): array {
|
): array {
|
||||||
$differenceInDays = $startDate->diff($targetDate)->days;
|
|
||||||
$dayCount = $differenceInDays + 1;
|
|
||||||
$elementCount = count($elements);
|
|
||||||
$assignments = [];
|
$assignments = [];
|
||||||
|
|
||||||
foreach ($elements as $index => $element) {
|
foreach ($elements as $index => $element) {
|
||||||
$dayIndex = $this->dayIndex(
|
|
||||||
elementIndex: $index,
|
|
||||||
elementCount: $elementCount,
|
|
||||||
dayCount: $dayCount,
|
|
||||||
);
|
|
||||||
$assignments[] = new CreateScheduleAssignmentDto(
|
$assignments[] = new CreateScheduleAssignmentDto(
|
||||||
name: $element->getName(),
|
name: $element->getName(),
|
||||||
kind: $element->getKind(),
|
kind: $element->getKind(),
|
||||||
path: $this->elementPath($element),
|
path: $this->elementPath($element),
|
||||||
scheduledDate: $startDate->modify("+{$dayIndex} days"),
|
scheduledDate: $scheduledDates[$index],
|
||||||
position: $index + 1,
|
position: $index + 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -190,25 +193,6 @@ class CreateSchedule
|
||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dayIndex(
|
|
||||||
int $elementIndex,
|
|
||||||
int $elementCount,
|
|
||||||
int $dayCount,
|
|
||||||
): int {
|
|
||||||
if ($elementCount === 1 || $dayCount === 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($elementCount < $dayCount) {
|
|
||||||
$scaledIndex = $elementIndex * ($dayCount - 1)
|
|
||||||
/ ($elementCount - 1);
|
|
||||||
|
|
||||||
return (int) floor($scaledIndex + 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
return intdiv($elementIndex * $dayCount, $elementCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
|
|
@ -237,4 +221,23 @@ class CreateSchedule
|
||||||
|
|
||||||
return $date;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ final readonly class CreateScheduleRequest
|
||||||
public ?int $levelId,
|
public ?int $levelId,
|
||||||
public ?string $startDate,
|
public ?string $startDate,
|
||||||
public ?string $targetDate,
|
public ?string $targetDate,
|
||||||
|
public ?string $workloadPlacement,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
backend/app/Schedule/WorkloadPlacement.php
Normal file
10
backend/app/Schedule/WorkloadPlacement.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
enum WorkloadPlacement: string
|
||||||
|
{
|
||||||
|
case Start = 'start';
|
||||||
|
case Middle = 'middle';
|
||||||
|
case End = 'end';
|
||||||
|
}
|
||||||
|
|
@ -160,6 +160,65 @@ class ScheduleEndpointTest extends TestCase
|
||||||
->assertExactJson($response->json());
|
->assertExactJson($response->json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_places_heavier_days_at_the_requested_end(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$set = $this->createSet($user, 'Course');
|
||||||
|
$lessonLevel = $this->createLevel($set, 'lesson');
|
||||||
|
$repository = app(ElementRepository::class);
|
||||||
|
|
||||||
|
foreach (range(1, 11) as $number) {
|
||||||
|
$repository->create(new CreateElementDto(
|
||||||
|
name: "Lesson {$number}",
|
||||||
|
level: $lessonLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$this->createSession($user, 'valid-token');
|
||||||
|
|
||||||
|
$response = $this->credentialedPost('/api/schedules', [
|
||||||
|
'setId' => $set->getId(),
|
||||||
|
'levelId' => $lessonLevel->getId(),
|
||||||
|
'startDate' => '2026-08-10',
|
||||||
|
'targetDate' => '2026-08-14',
|
||||||
|
'workloadPlacement' => 'end',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$days = $response->json('schedule.days');
|
||||||
|
$this->assertIsArray($days);
|
||||||
|
$this->assertSame(
|
||||||
|
[2, 2, 2, 2, 3],
|
||||||
|
array_map(function (array $day): int {
|
||||||
|
return count($day['assignments']);
|
||||||
|
}, $days),
|
||||||
|
);
|
||||||
|
$this->assertDatabaseCount('schedule_assignments', 11);
|
||||||
|
$this->assertDatabaseCount('schedules', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_unknown_workload_placement(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$set = $this->createSet($user, 'Course');
|
||||||
|
$lessonLevel = $this->createLevel($set, 'lesson');
|
||||||
|
app(ElementRepository::class)->create(new CreateElementDto(
|
||||||
|
name: 'Lesson 1',
|
||||||
|
level: $lessonLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
$this->createSession($user, 'valid-token');
|
||||||
|
|
||||||
|
$this->credentialedPost('/api/schedules', [
|
||||||
|
'setId' => $set->getId(),
|
||||||
|
'levelId' => $lessonLevel->getId(),
|
||||||
|
'startDate' => '2026-08-10',
|
||||||
|
'targetDate' => '2026-08-14',
|
||||||
|
'workloadPlacement' => 'sideways',
|
||||||
|
])->assertBadRequest()->assertExactJson([
|
||||||
|
'error' => 'workloadPlacement must be start, middle, or end',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_lists_only_the_users_schedules_newest_first(): void
|
public function test_it_lists_only_the_users_schedules_newest_first(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('reader@example.com');
|
$user = $this->createUser('reader@example.com');
|
||||||
|
|
|
||||||
129
backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php
Normal file
129
backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Schedule;
|
||||||
|
|
||||||
|
use App\Schedule\EvenDistributionScheduler;
|
||||||
|
use App\Schedule\WorkloadPlacement;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class EvenDistributionSchedulerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_places_one_heavier_day_at_the_requested_position(): void
|
||||||
|
{
|
||||||
|
$scheduler = new EvenDistributionScheduler;
|
||||||
|
$expectedCounts = [
|
||||||
|
WorkloadPlacement::Start->value => [3, 2, 2, 2, 2],
|
||||||
|
WorkloadPlacement::Middle->value => [2, 2, 3, 2, 2],
|
||||||
|
WorkloadPlacement::End->value => [2, 2, 2, 2, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (WorkloadPlacement::cases() as $placement) {
|
||||||
|
$dates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 11,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-14'),
|
||||||
|
workloadPlacement: $placement,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$expectedCounts[$placement->value],
|
||||||
|
$this->dailyCounts($dates),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_keeps_multiple_heavier_days_together(): void
|
||||||
|
{
|
||||||
|
$scheduler = new EvenDistributionScheduler;
|
||||||
|
$expectedCounts = [
|
||||||
|
WorkloadPlacement::Start->value => [3, 3, 2, 2, 2],
|
||||||
|
WorkloadPlacement::Middle->value => [2, 3, 3, 2, 2],
|
||||||
|
WorkloadPlacement::End->value => [2, 2, 2, 3, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (WorkloadPlacement::cases() as $placement) {
|
||||||
|
$dates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 12,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-14'),
|
||||||
|
workloadPlacement: $placement,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$expectedCounts[$placement->value],
|
||||||
|
$this->dailyCounts($dates),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_preserves_sparse_distribution_across_the_full_range(): void
|
||||||
|
{
|
||||||
|
$dates = (new EvenDistributionScheduler)->scheduledDates(
|
||||||
|
assignmentCount: 3,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-16'),
|
||||||
|
workloadPlacement: WorkloadPlacement::Middle,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['2026-08-10', '2026-08-13', '2026-08-16'],
|
||||||
|
$this->formattedDates($dates),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_distributes_even_and_single_day_schedules(): void
|
||||||
|
{
|
||||||
|
$scheduler = new EvenDistributionScheduler;
|
||||||
|
|
||||||
|
$evenDates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 10,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-14'),
|
||||||
|
workloadPlacement: WorkloadPlacement::End,
|
||||||
|
);
|
||||||
|
$singleDayDates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 4,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-10'),
|
||||||
|
workloadPlacement: WorkloadPlacement::Middle,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame([2, 2, 2, 2, 2], $this->dailyCounts($evenDates));
|
||||||
|
$this->assertSame(
|
||||||
|
[
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-10',
|
||||||
|
],
|
||||||
|
$this->formattedDates($singleDayDates),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<DateTimeImmutable> $dates
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function dailyCounts(array $dates): array
|
||||||
|
{
|
||||||
|
return array_values(array_count_values($this->formattedDates($dates)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<DateTimeImmutable> $dates
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function formattedDates(array $dates): array
|
||||||
|
{
|
||||||
|
return array_map(function (DateTimeImmutable $date): string {
|
||||||
|
return $date->format('Y-m-d');
|
||||||
|
}, $dates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function utc(string $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable($date, new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace Tests\Unit\Schedule\UseCases;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Exceptions\BadRequestException;
|
use App\Exceptions\BadRequestException;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
|
use App\Schedule\EvenDistributionScheduler;
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
|
||||||
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
|
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
|
||||||
use App\Set\CreateSetDto;
|
use App\Set\CreateSetDto;
|
||||||
|
|
@ -108,12 +109,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
$elementRepository,
|
$elementRepository,
|
||||||
$scheduleRepository,
|
$scheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $chapterLevel->getId(),
|
levelId: $chapterLevel->getId(),
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame('chapter', $schedule->getElementKind());
|
$this->assertSame('chapter', $schedule->getElementKind());
|
||||||
|
|
@ -182,12 +185,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
$elementRepository,
|
$elementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $lessonLevel->getId(),
|
levelId: $lessonLevel->getId(),
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-16',
|
targetDate: '2026-08-16',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
|
|
@ -224,12 +229,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
$elementRepository,
|
$elementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $milestoneLevel->getId(),
|
levelId: $milestoneLevel->getId(),
|
||||||
startDate: '2020-01-01',
|
startDate: '2020-01-01',
|
||||||
targetDate: '2030-01-01',
|
targetDate: '2030-01-01',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
|
|
@ -240,6 +247,102 @@ class CreateScheduleTest extends TestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_defaults_heavier_days_to_the_middle(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$setRepository = new FakeSetRepository;
|
||||||
|
$setLevelRepository = new FakeSetLevelRepository;
|
||||||
|
$elementRepository = new FakeElementRepository;
|
||||||
|
$set = $setRepository->create(new CreateSetDto(
|
||||||
|
name: 'Course',
|
||||||
|
creator: $user,
|
||||||
|
));
|
||||||
|
$lessonLevel = $this->createLevel(
|
||||||
|
$setLevelRepository,
|
||||||
|
$set,
|
||||||
|
'lesson',
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (range(1, 6) as $number) {
|
||||||
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
name: "Lesson {$number}",
|
||||||
|
level: $lessonLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$schedule = (new CreateSchedule(
|
||||||
|
$setRepository,
|
||||||
|
$setLevelRepository,
|
||||||
|
$elementRepository,
|
||||||
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
|
))->execute(new CreateScheduleRequest(
|
||||||
|
user: $user,
|
||||||
|
setId: $set->getId(),
|
||||||
|
levelId: $lessonLevel->getId(),
|
||||||
|
startDate: '2026-08-10',
|
||||||
|
targetDate: '2026-08-13',
|
||||||
|
workloadPlacement: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
],
|
||||||
|
array_map(function ($assignment): string {
|
||||||
|
return $assignment->getScheduledDate()->format('Y-m-d');
|
||||||
|
}, $schedule->getAssignments()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_unknown_workload_placement(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$setRepository = new FakeSetRepository;
|
||||||
|
$setLevelRepository = new FakeSetLevelRepository;
|
||||||
|
$elementRepository = new FakeElementRepository;
|
||||||
|
$set = $setRepository->create(new CreateSetDto(
|
||||||
|
name: 'Course',
|
||||||
|
creator: $user,
|
||||||
|
));
|
||||||
|
$lessonLevel = $this->createLevel(
|
||||||
|
$setLevelRepository,
|
||||||
|
$set,
|
||||||
|
'lesson',
|
||||||
|
);
|
||||||
|
$elementRepository->create(new CreateElementDto(
|
||||||
|
name: 'Lesson 1',
|
||||||
|
level: $lessonLevel,
|
||||||
|
parentElement: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'workloadPlacement must be start, middle, or end',
|
||||||
|
);
|
||||||
|
|
||||||
|
(new CreateSchedule(
|
||||||
|
$setRepository,
|
||||||
|
$setLevelRepository,
|
||||||
|
$elementRepository,
|
||||||
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
|
))->execute(new CreateScheduleRequest(
|
||||||
|
user: $user,
|
||||||
|
setId: $set->getId(),
|
||||||
|
levelId: $lessonLevel->getId(),
|
||||||
|
startDate: '2026-08-10',
|
||||||
|
targetDate: '2026-08-13',
|
||||||
|
workloadPlacement: 'sideways',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_unknown_set(): void
|
public function test_it_rejects_an_unknown_set(): void
|
||||||
{
|
{
|
||||||
$this->expectException(NotFoundException::class);
|
$this->expectException(NotFoundException::class);
|
||||||
|
|
@ -250,12 +353,14 @@ class CreateScheduleTest extends TestCase
|
||||||
new FakeSetLevelRepository,
|
new FakeSetLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $this->user(),
|
user: $this->user(),
|
||||||
setId: 999,
|
setId: 999,
|
||||||
levelId: 1,
|
levelId: 1,
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,12 +381,14 @@ class CreateScheduleTest extends TestCase
|
||||||
new FakeSetLevelRepository,
|
new FakeSetLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: null,
|
levelId: null,
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,12 +419,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $bible->getId(),
|
setId: $bible->getId(),
|
||||||
levelId: $moduleLevel->getId(),
|
levelId: $moduleLevel->getId(),
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,12 +453,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $chapterLevel->getId(),
|
levelId: $chapterLevel->getId(),
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,12 +487,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $chapterLevel->getId(),
|
levelId: $chapterLevel->getId(),
|
||||||
startDate: '2026-02-30',
|
startDate: '2026-02-30',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,12 +523,14 @@ class CreateScheduleTest extends TestCase
|
||||||
$setLevelRepository,
|
$setLevelRepository,
|
||||||
new FakeElementRepository,
|
new FakeElementRepository,
|
||||||
new FakeScheduleRepository,
|
new FakeScheduleRepository,
|
||||||
|
new EvenDistributionScheduler,
|
||||||
))->execute(new CreateScheduleRequest(
|
))->execute(new CreateScheduleRequest(
|
||||||
user: $user,
|
user: $user,
|
||||||
setId: $set->getId(),
|
setId: $set->getId(),
|
||||||
levelId: $chapterLevel->getId(),
|
levelId: $chapterLevel->getId(),
|
||||||
startDate: '2026-08-12',
|
startDate: '2026-08-12',
|
||||||
targetDate: '2026-08-10',
|
targetDate: '2026-08-10',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,13 @@ const bibleLayout = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overloadedBibleLayout = {
|
||||||
|
...bibleLayout,
|
||||||
|
levels: bibleLayout.levels.map((level) =>
|
||||||
|
level.id === 3 ? { ...level, elementCount: 11 } : level,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleDetail = {
|
const scheduleDetail = {
|
||||||
schedule: {
|
schedule: {
|
||||||
id: 73,
|
id: 73,
|
||||||
|
|
@ -194,6 +201,7 @@ describe('set scheduling', () => {
|
||||||
levelId: 3,
|
levelId: 3,
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-12',
|
targetDate: '2026-08-12',
|
||||||
|
workloadPlacement: 'middle',
|
||||||
})
|
})
|
||||||
request.reply({ statusCode: 201, body: scheduleDetail })
|
request.reply({ statusCode: 201, body: scheduleDetail })
|
||||||
}).as('createSchedule')
|
}).as('createSchedule')
|
||||||
|
|
@ -244,6 +252,40 @@ describe('set scheduling', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places heavier days where the user chooses', () => {
|
||||||
|
cy.intercept('GET', '**/api/sets/41', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: overloadedBibleLayout,
|
||||||
|
}).as('layout')
|
||||||
|
cy.intercept('POST', '**/api/schedules', (request) => {
|
||||||
|
expect(request.body).to.deep.equal({
|
||||||
|
setId: 41,
|
||||||
|
levelId: 3,
|
||||||
|
startDate: '2026-08-10',
|
||||||
|
targetDate: '2026-08-14',
|
||||||
|
workloadPlacement: 'end',
|
||||||
|
})
|
||||||
|
request.reply({ statusCode: 201, body: scheduleDetail })
|
||||||
|
}).as('createSchedule')
|
||||||
|
|
||||||
|
cy.visit('/sets/41/schedules/new')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.wait('@layout')
|
||||||
|
cy.get('[data-workload-placement]').should('not.exist')
|
||||||
|
|
||||||
|
cy.get('#schedule-level').select('3')
|
||||||
|
cy.get('#schedule-start-date').type('2026-08-10')
|
||||||
|
cy.get('#schedule-target-date').type('2026-08-14')
|
||||||
|
|
||||||
|
cy.get('[data-workload-placement]').should('be.visible').within(() => {
|
||||||
|
cy.contains('legend', 'Heavier days').should('be.visible')
|
||||||
|
cy.get('input[value="middle"]').should('be.checked')
|
||||||
|
cy.get('input[value="end"]').check()
|
||||||
|
})
|
||||||
|
cy.get('form').submit()
|
||||||
|
cy.wait('@createSchedule')
|
||||||
|
})
|
||||||
|
|
||||||
it('validates the schedule form before submitting', () => {
|
it('validates the schedule form before submitting', () => {
|
||||||
cy.intercept('GET', '**/api/sets/41', {
|
cy.intercept('GET', '**/api/sets/41', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { API_BASE } from '@/utils/apiBase'
|
||||||
|
|
||||||
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|
||||||
|
export const workloadPlacementSchema = z.enum(['start', 'middle', 'end'])
|
||||||
|
|
||||||
export const scheduleSummarySchema = z.object({
|
export const scheduleSummarySchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
set: z.object({
|
set: z.object({
|
||||||
|
|
@ -76,11 +78,13 @@ const errorResponseSchema = z.object({
|
||||||
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
|
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
|
||||||
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
|
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
|
||||||
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
|
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
|
||||||
|
export type WorkloadPlacement = z.infer<typeof workloadPlacementSchema>
|
||||||
export type CreateScheduleInput = {
|
export type CreateScheduleInput = {
|
||||||
setId: number
|
setId: number
|
||||||
levelId: number
|
levelId: number
|
||||||
startDate: string
|
startDate: string
|
||||||
targetDate: string
|
targetDate: string
|
||||||
|
workloadPlacement: WorkloadPlacement
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIST_ERROR = "We couldn't load your schedules."
|
const LIST_ERROR = "We couldn't load your schedules."
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import { useRoute, useRouter } from 'vue-router'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue'
|
||||||
import { useSchedulesStore } from '@/stores/schedules'
|
import {
|
||||||
|
useSchedulesStore,
|
||||||
|
workloadPlacementSchema,
|
||||||
|
type WorkloadPlacement,
|
||||||
|
} from '@/stores/schedules'
|
||||||
import { useSetLayoutStore } from '@/stores/setLayout'
|
import { useSetLayoutStore } from '@/stores/setLayout'
|
||||||
|
|
||||||
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
|
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
|
||||||
|
|
@ -22,18 +26,51 @@ const form = reactive({
|
||||||
levelId: null as number | null,
|
levelId: null as number | null,
|
||||||
startDate: '',
|
startDate: '',
|
||||||
targetDate: '',
|
targetDate: '',
|
||||||
|
workloadPlacement: 'middle' as WorkloadPlacement,
|
||||||
})
|
})
|
||||||
const fieldErrors = ref<ScheduleFieldErrors>({})
|
const fieldErrors = ref<ScheduleFieldErrors>({})
|
||||||
|
|
||||||
const levels = computed(() =>
|
const levels = computed(() =>
|
||||||
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
|
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
|
||||||
)
|
)
|
||||||
|
const selectedLevel = computed(() => levels.value.find((level) => level.id === form.levelId))
|
||||||
|
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 showWorkloadPlacement = computed(() => {
|
||||||
|
if (
|
||||||
|
selectedLevel.value === undefined ||
|
||||||
|
form.startDate === '' ||
|
||||||
|
form.targetDate === '' ||
|
||||||
|
form.targetDate < form.startDate
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.parse(`${form.startDate}T00:00:00Z`)
|
||||||
|
const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`)
|
||||||
|
if (Number.isNaN(startTime) || Number.isNaN(targetTime)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const millisecondsPerDay = 24 * 60 * 60 * 1000
|
||||||
|
const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1
|
||||||
|
const assignmentCount = selectedLevel.value.elementCount
|
||||||
|
|
||||||
|
return assignmentCount > dayCount && assignmentCount % dayCount !== 0
|
||||||
|
})
|
||||||
|
|
||||||
const scheduleFormSchema = z
|
const scheduleFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
levelId: z.number('Choose a level to schedule.').int().positive(),
|
levelId: z.number('Choose a level to schedule.').int().positive(),
|
||||||
startDate: z.string().min(1, 'Choose a start date.'),
|
startDate: z.string().min(1, 'Choose a start date.'),
|
||||||
targetDate: z.string().min(1, 'Choose a target date.'),
|
targetDate: z.string().min(1, 'Choose a target date.'),
|
||||||
|
workloadPlacement: workloadPlacementSchema,
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
|
if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) {
|
||||||
|
|
@ -194,6 +231,33 @@ async function retry(): Promise<void> {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
v-if="showWorkloadPlacement"
|
||||||
|
class="workload-placement"
|
||||||
|
data-workload-placement
|
||||||
|
:disabled="creating"
|
||||||
|
>
|
||||||
|
<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="workloadPlacement"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<p v-if="createError !== null" class="form-error" role="alert">{{ createError }}</p>
|
<p v-if="createError !== null" class="form-error" role="alert">{{ createError }}</p>
|
||||||
|
|
||||||
<button type="submit" class="primary-button" :disabled="creating">
|
<button type="submit" class="primary-button" :disabled="creating">
|
||||||
|
|
@ -290,6 +354,57 @@ h1 {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workload-placement {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement legend {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement > p {
|
||||||
|
margin: 0;
|
||||||
|
color: #68776f;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement__options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid rgb(24 58 49 / 22%);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
background: #fffdf7;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement__option:has(input:checked) {
|
||||||
|
border-color: #183a31;
|
||||||
|
box-shadow: 0 0 0 1px #183a31;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workload-placement__option input {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|
@ -380,5 +495,9 @@ input[aria-invalid='true'] {
|
||||||
.date-fields {
|
.date-fields {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workload-placement__options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue