Compare commits
12 commits
6c8250b3a4
...
a06bb6f003
| Author | SHA1 | Date | |
|---|---|---|---|
| a06bb6f003 | |||
| d2aad964d2 | |||
| 0a7848a342 | |||
| 6dc2ccbb55 | |||
| 85e2c33c31 | |||
| becd7573c6 | |||
| 6f2c49026a | |||
| 90dcc8665a | |||
| ff8efd7f4a | |||
| 04de3117ae | |||
| 4a9522f49d | |||
| add0684610 |
10 changed files with 375 additions and 91 deletions
10
README.md
10
README.md
|
|
@ -38,10 +38,12 @@ 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
|
Attainly then distributes the selected elements across the available days.
|
||||||
days. When an uneven schedule has more than one assignment per day, the user
|
When there are fewer assignments than days, the user can spread them across
|
||||||
can place the consecutive heavier days at the start, middle, or end of the
|
the full range or pack them into consecutive days at the start, middle, or
|
||||||
schedule.
|
end. When an uneven schedule has more than one assignment per day, the user
|
||||||
|
can spread the heavier days across the full range or keep them together at
|
||||||
|
the start, middle, or end.
|
||||||
|
|
||||||
For example, a user could choose to schedule:
|
For example, a user could choose to schedule:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,7 @@ class EvenDistributionScheduler
|
||||||
$differenceInDays = $startDate->diff($targetDate)->days;
|
$differenceInDays = $startDate->diff($targetDate)->days;
|
||||||
$dayCount = $differenceInDays + 1;
|
$dayCount = $differenceInDays + 1;
|
||||||
|
|
||||||
if ($assignmentCount < $dayCount) {
|
return $this->distributedDates(
|
||||||
return $this->sparseDates(
|
|
||||||
assignmentCount: $assignmentCount,
|
|
||||||
dayCount: $dayCount,
|
|
||||||
startDate: $startDate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->dailyDates(
|
|
||||||
assignmentCount: $assignmentCount,
|
assignmentCount: $assignmentCount,
|
||||||
dayCount: $dayCount,
|
dayCount: $dayCount,
|
||||||
startDate: $startDate,
|
startDate: $startDate,
|
||||||
|
|
@ -37,53 +29,26 @@ class EvenDistributionScheduler
|
||||||
/**
|
/**
|
||||||
* @return list<DateTimeImmutable>
|
* @return list<DateTimeImmutable>
|
||||||
*/
|
*/
|
||||||
private function sparseDates(
|
private function distributedDates(
|
||||||
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 $assignmentCount,
|
||||||
int $dayCount,
|
int $dayCount,
|
||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
WorkloadPlacement $workloadPlacement,
|
WorkloadPlacement $workloadPlacement,
|
||||||
): array {
|
): array {
|
||||||
$assignmentsPerDay = intdiv($assignmentCount, $dayCount);
|
$assignmentsPerDay = intdiv($assignmentCount, $dayCount);
|
||||||
$heavierDayCount = $assignmentCount % $dayCount;
|
$remainderDayCount = $assignmentCount % $dayCount;
|
||||||
$heavierBlockStart = $this->heavierBlockStart(
|
$remainderDayIndexes = $this->remainderDayIndexes(
|
||||||
|
assignmentsPerDay: $assignmentsPerDay,
|
||||||
dayCount: $dayCount,
|
dayCount: $dayCount,
|
||||||
heavierDayCount: $heavierDayCount,
|
remainderDayCount: $remainderDayCount,
|
||||||
workloadPlacement: $workloadPlacement,
|
workloadPlacement: $workloadPlacement,
|
||||||
);
|
);
|
||||||
|
$remainderDays = array_fill_keys($remainderDayIndexes, true);
|
||||||
$dates = [];
|
$dates = [];
|
||||||
|
|
||||||
for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) {
|
for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) {
|
||||||
$assignmentCountForDay = $assignmentsPerDay;
|
$assignmentCountForDay = $assignmentsPerDay;
|
||||||
if (
|
if (isset($remainderDays[$dayIndex])) {
|
||||||
$dayIndex >= $heavierBlockStart
|
|
||||||
&& $dayIndex < $heavierBlockStart + $heavierDayCount
|
|
||||||
) {
|
|
||||||
$assignmentCountForDay++;
|
$assignmentCountForDay++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,9 +61,68 @@ class EvenDistributionScheduler
|
||||||
return $dates;
|
return $dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function heavierBlockStart(
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function remainderDayIndexes(
|
||||||
|
int $assignmentsPerDay,
|
||||||
int $dayCount,
|
int $dayCount,
|
||||||
int $heavierDayCount,
|
int $remainderDayCount,
|
||||||
|
WorkloadPlacement $workloadPlacement,
|
||||||
|
): array {
|
||||||
|
if ($remainderDayCount === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($workloadPlacement === WorkloadPlacement::Spread) {
|
||||||
|
return $this->spreadRemainderDayIndexes(
|
||||||
|
assignmentsPerDay: $assignmentsPerDay,
|
||||||
|
dayCount: $dayCount,
|
||||||
|
remainderDayCount: $remainderDayCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$blockStart = $this->remainderBlockStart(
|
||||||
|
dayCount: $dayCount,
|
||||||
|
remainderDayCount: $remainderDayCount,
|
||||||
|
workloadPlacement: $workloadPlacement,
|
||||||
|
);
|
||||||
|
|
||||||
|
return range($blockStart, $blockStart + $remainderDayCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function spreadRemainderDayIndexes(
|
||||||
|
int $assignmentsPerDay,
|
||||||
|
int $dayCount,
|
||||||
|
int $remainderDayCount,
|
||||||
|
): array {
|
||||||
|
if ($remainderDayCount === 1) {
|
||||||
|
if ($assignmentsPerDay === 0) {
|
||||||
|
return [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [intdiv($dayCount - 1, 2)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayIndexes = [];
|
||||||
|
for ($remainderIndex = 0;
|
||||||
|
$remainderIndex < $remainderDayCount;
|
||||||
|
$remainderIndex++
|
||||||
|
) {
|
||||||
|
$scaledIndex = $remainderIndex * ($dayCount - 1)
|
||||||
|
/ ($remainderDayCount - 1);
|
||||||
|
$dayIndexes[] = (int) floor($scaledIndex + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dayIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function remainderBlockStart(
|
||||||
|
int $dayCount,
|
||||||
|
int $remainderDayCount,
|
||||||
WorkloadPlacement $workloadPlacement,
|
WorkloadPlacement $workloadPlacement,
|
||||||
): int {
|
): int {
|
||||||
if ($workloadPlacement === WorkloadPlacement::Start) {
|
if ($workloadPlacement === WorkloadPlacement::Start) {
|
||||||
|
|
@ -106,9 +130,9 @@ class EvenDistributionScheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($workloadPlacement === WorkloadPlacement::End) {
|
if ($workloadPlacement === WorkloadPlacement::End) {
|
||||||
return $dayCount - $heavierDayCount;
|
return $dayCount - $remainderDayCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return intdiv($dayCount - $heavierDayCount, 2);
|
return intdiv($dayCount - $remainderDayCount, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class CreateSchedule
|
||||||
'targetDate must not be before startDate',
|
'targetDate must not be before startDate',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$workloadPlacement = $this->workloadPlacement(
|
$requestedWorkloadPlacement = $this->workloadPlacement(
|
||||||
$request->workloadPlacement,
|
$request->workloadPlacement,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -78,6 +78,13 @@ class CreateSchedule
|
||||||
throw new BadRequestException('level has no elements');
|
throw new BadRequestException('level has no elements');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$workloadPlacement = $this->resolvedWorkloadPlacement(
|
||||||
|
requestedWorkloadPlacement: $requestedWorkloadPlacement,
|
||||||
|
assignmentCount: count($elements),
|
||||||
|
startDate: $startDate,
|
||||||
|
targetDate: $targetDate,
|
||||||
|
);
|
||||||
|
|
||||||
$scheduledDates = $this->evenDistributionScheduler->scheduledDates(
|
$scheduledDates = $this->evenDistributionScheduler->scheduledDates(
|
||||||
assignmentCount: count($elements),
|
assignmentCount: count($elements),
|
||||||
startDate: $startDate,
|
startDate: $startDate,
|
||||||
|
|
@ -225,19 +232,38 @@ class CreateSchedule
|
||||||
/**
|
/**
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
private function workloadPlacement(?string $value): WorkloadPlacement
|
private function workloadPlacement(?string $value): ?WorkloadPlacement
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
return WorkloadPlacement::Middle;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$workloadPlacement = WorkloadPlacement::tryFrom($value);
|
$workloadPlacement = WorkloadPlacement::tryFrom($value);
|
||||||
if ($workloadPlacement === null) {
|
if ($workloadPlacement === null) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'workloadPlacement must be start, middle, or end',
|
'workloadPlacement must be spread, start, middle, or end',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $workloadPlacement;
|
return $workloadPlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvedWorkloadPlacement(
|
||||||
|
?WorkloadPlacement $requestedWorkloadPlacement,
|
||||||
|
int $assignmentCount,
|
||||||
|
DateTimeImmutable $startDate,
|
||||||
|
DateTimeImmutable $targetDate,
|
||||||
|
): WorkloadPlacement {
|
||||||
|
$differenceInDays = $startDate->diff($targetDate)->days;
|
||||||
|
$dayCount = $differenceInDays + 1;
|
||||||
|
$isSparse = $assignmentCount < $dayCount;
|
||||||
|
|
||||||
|
if ($requestedWorkloadPlacement === null) {
|
||||||
|
return $isSparse
|
||||||
|
? WorkloadPlacement::Spread
|
||||||
|
: WorkloadPlacement::Middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $requestedWorkloadPlacement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Schedule;
|
||||||
|
|
||||||
enum WorkloadPlacement: string
|
enum WorkloadPlacement: string
|
||||||
{
|
{
|
||||||
|
case Spread = 'spread';
|
||||||
case Start = 'start';
|
case Start = 'start';
|
||||||
case Middle = 'middle';
|
case Middle = 'middle';
|
||||||
case End = 'end';
|
case End = 'end';
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,40 @@ class ScheduleEndpointTest extends TestCase
|
||||||
$this->assertDatabaseCount('schedules', 1);
|
$this->assertDatabaseCount('schedules', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_packs_sparse_assignments_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, 4) 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-15',
|
||||||
|
'workloadPlacement' => 'end',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$days = $response->json('schedule.days');
|
||||||
|
$this->assertIsArray($days);
|
||||||
|
$this->assertSame(
|
||||||
|
[0, 0, 1, 1, 1, 1],
|
||||||
|
array_map(function (array $day): int {
|
||||||
|
return count($day['assignments']);
|
||||||
|
}, $days),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_unknown_workload_placement(): void
|
public function test_it_rejects_an_unknown_workload_placement(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('reader@example.com');
|
$user = $this->createUser('reader@example.com');
|
||||||
|
|
@ -215,10 +249,44 @@ class ScheduleEndpointTest extends TestCase
|
||||||
'targetDate' => '2026-08-14',
|
'targetDate' => '2026-08-14',
|
||||||
'workloadPlacement' => 'sideways',
|
'workloadPlacement' => 'sideways',
|
||||||
])->assertBadRequest()->assertExactJson([
|
])->assertBadRequest()->assertExactJson([
|
||||||
'error' => 'workloadPlacement must be start, middle, or end',
|
'error' => 'workloadPlacement must be spread, start, middle, or end',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_spreads_heavier_days_across_the_full_range(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('reader@example.com');
|
||||||
|
$set = $this->createSet($user, 'Course');
|
||||||
|
$lessonLevel = $this->createLevel($set, 'lesson');
|
||||||
|
$repository = app(ElementRepository::class);
|
||||||
|
|
||||||
|
foreach (range(1, 13) 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' => 'spread',
|
||||||
|
])->assertCreated();
|
||||||
|
|
||||||
|
$days = $response->json('schedule.days');
|
||||||
|
$this->assertIsArray($days);
|
||||||
|
$this->assertSame(
|
||||||
|
[3, 2, 3, 2, 3],
|
||||||
|
array_map(function (array $day): int {
|
||||||
|
return count($day['assignments']);
|
||||||
|
}, $days),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ class EvenDistributionSchedulerTest extends TestCase
|
||||||
WorkloadPlacement::End->value => [2, 2, 2, 2, 3],
|
WorkloadPlacement::End->value => [2, 2, 2, 2, 3],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (WorkloadPlacement::cases() as $placement) {
|
foreach ($expectedCounts as $placementValue => $expectedDailyCounts) {
|
||||||
|
$placement = WorkloadPlacement::from($placementValue);
|
||||||
$dates = $scheduler->scheduledDates(
|
$dates = $scheduler->scheduledDates(
|
||||||
assignmentCount: 11,
|
assignmentCount: 11,
|
||||||
startDate: $this->utc('2026-08-10'),
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
|
@ -28,7 +29,7 @@ class EvenDistributionSchedulerTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
$expectedCounts[$placement->value],
|
$expectedDailyCounts,
|
||||||
$this->dailyCounts($dates),
|
$this->dailyCounts($dates),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +44,8 @@ class EvenDistributionSchedulerTest extends TestCase
|
||||||
WorkloadPlacement::End->value => [2, 2, 2, 3, 3],
|
WorkloadPlacement::End->value => [2, 2, 2, 3, 3],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (WorkloadPlacement::cases() as $placement) {
|
foreach ($expectedCounts as $placementValue => $expectedDailyCounts) {
|
||||||
|
$placement = WorkloadPlacement::from($placementValue);
|
||||||
$dates = $scheduler->scheduledDates(
|
$dates = $scheduler->scheduledDates(
|
||||||
assignmentCount: 12,
|
assignmentCount: 12,
|
||||||
startDate: $this->utc('2026-08-10'),
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
|
@ -52,26 +54,102 @@ class EvenDistributionSchedulerTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
$expectedCounts[$placement->value],
|
$expectedDailyCounts,
|
||||||
$this->dailyCounts($dates),
|
$this->dailyCounts($dates),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_preserves_sparse_distribution_across_the_full_range(): void
|
public function test_it_spreads_heavier_days_across_the_full_range(): void
|
||||||
{
|
{
|
||||||
$dates = (new EvenDistributionScheduler)->scheduledDates(
|
$scheduler = new EvenDistributionScheduler;
|
||||||
assignmentCount: 3,
|
$expectedCounts = [
|
||||||
|
11 => [2, 2, 3, 2, 2],
|
||||||
|
12 => [3, 2, 2, 2, 3],
|
||||||
|
13 => [3, 2, 3, 2, 3],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($expectedCounts as $assignmentCount => $dailyCounts) {
|
||||||
|
$dates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: $assignmentCount,
|
||||||
startDate: $this->utc('2026-08-10'),
|
startDate: $this->utc('2026-08-10'),
|
||||||
targetDate: $this->utc('2026-08-16'),
|
targetDate: $this->utc('2026-08-14'),
|
||||||
workloadPlacement: WorkloadPlacement::Middle,
|
workloadPlacement: WorkloadPlacement::Spread,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame($dailyCounts, $this->dailyCounts($dates));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_places_sparse_assignments_at_the_requested_position(): void
|
||||||
|
{
|
||||||
|
$scheduler = new EvenDistributionScheduler;
|
||||||
|
$expectedDates = [
|
||||||
|
WorkloadPlacement::Spread->value => [
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
'2026-08-15',
|
||||||
|
],
|
||||||
|
WorkloadPlacement::Start->value => [
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
],
|
||||||
|
WorkloadPlacement::Middle->value => [
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
'2026-08-14',
|
||||||
|
],
|
||||||
|
WorkloadPlacement::End->value => [
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
'2026-08-14',
|
||||||
|
'2026-08-15',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (WorkloadPlacement::cases() as $placement) {
|
||||||
|
$dates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 4,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-15'),
|
||||||
|
workloadPlacement: $placement,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
['2026-08-10', '2026-08-13', '2026-08-16'],
|
$expectedDates[$placement->value],
|
||||||
$this->formattedDates($dates),
|
$this->formattedDates($dates),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_places_one_assignment_at_the_requested_position(): void
|
||||||
|
{
|
||||||
|
$scheduler = new EvenDistributionScheduler;
|
||||||
|
$expectedDates = [
|
||||||
|
WorkloadPlacement::Spread->value => '2026-08-10',
|
||||||
|
WorkloadPlacement::Start->value => '2026-08-10',
|
||||||
|
WorkloadPlacement::Middle->value => '2026-08-12',
|
||||||
|
WorkloadPlacement::End->value => '2026-08-15',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (WorkloadPlacement::cases() as $placement) {
|
||||||
|
$dates = $scheduler->scheduledDates(
|
||||||
|
assignmentCount: 1,
|
||||||
|
startDate: $this->utc('2026-08-10'),
|
||||||
|
targetDate: $this->utc('2026-08-15'),
|
||||||
|
workloadPlacement: $placement,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
[$expectedDates[$placement->value]],
|
||||||
|
$this->formattedDates($dates),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_distributes_even_and_single_day_schedules(): void
|
public function test_it_distributes_even_and_single_day_schedules(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class CreateScheduleTest extends TestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_spreads_sparse_work_across_the_full_range(): void
|
public function test_it_defaults_sparse_work_to_spread_across_the_full_range(): void
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$setRepository = new FakeSetRepository;
|
$setRepository = new FakeSetRepository;
|
||||||
|
|
@ -192,7 +192,7 @@ class CreateScheduleTest extends TestCase
|
||||||
levelId: $lessonLevel->getId(),
|
levelId: $lessonLevel->getId(),
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-16',
|
targetDate: '2026-08-16',
|
||||||
workloadPlacement: 'middle',
|
workloadPlacement: null,
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
|
|
@ -203,7 +203,7 @@ class CreateScheduleTest extends TestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_places_one_element_on_the_start_date(): void
|
public function test_it_places_one_element_at_the_requested_middle(): void
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$setRepository = new FakeSetRepository;
|
$setRepository = new FakeSetRepository;
|
||||||
|
|
@ -240,7 +240,7 @@ class CreateScheduleTest extends TestCase
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'2020-01-01',
|
'2024-12-31',
|
||||||
$schedule->getAssignments()[0]
|
$schedule->getAssignments()[0]
|
||||||
->getScheduledDate()
|
->getScheduledDate()
|
||||||
->format('Y-m-d'),
|
->format('Y-m-d'),
|
||||||
|
|
@ -324,7 +324,7 @@ class CreateScheduleTest extends TestCase
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
$this->expectException(BadRequestException::class);
|
||||||
$this->expectExceptionMessage(
|
$this->expectExceptionMessage(
|
||||||
'workloadPlacement must be start, middle, or end',
|
'workloadPlacement must be spread, start, middle, or end',
|
||||||
);
|
);
|
||||||
|
|
||||||
(new CreateSchedule(
|
(new CreateSchedule(
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,13 @@ const overloadedBibleLayout = {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sparseBibleLayout = {
|
||||||
|
...bibleLayout,
|
||||||
|
levels: bibleLayout.levels.map((level) =>
|
||||||
|
level.id === 3 ? { ...level, elementCount: 4 } : level,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleDetail = {
|
const scheduleDetail = {
|
||||||
schedule: {
|
schedule: {
|
||||||
id: 73,
|
id: 73,
|
||||||
|
|
@ -201,7 +208,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',
|
workloadPlacement: 'spread',
|
||||||
})
|
})
|
||||||
request.reply({ statusCode: 201, body: scheduleDetail })
|
request.reply({ statusCode: 201, body: scheduleDetail })
|
||||||
}).as('createSchedule')
|
}).as('createSchedule')
|
||||||
|
|
@ -252,7 +259,7 @@ describe('set scheduling', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('places heavier days where the user chooses', () => {
|
it('spreads heavier days where the user chooses', () => {
|
||||||
cy.intercept('GET', '**/api/sets/41', {
|
cy.intercept('GET', '**/api/sets/41', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: overloadedBibleLayout,
|
body: overloadedBibleLayout,
|
||||||
|
|
@ -263,7 +270,7 @@ describe('set scheduling', () => {
|
||||||
levelId: 3,
|
levelId: 3,
|
||||||
startDate: '2026-08-10',
|
startDate: '2026-08-10',
|
||||||
targetDate: '2026-08-14',
|
targetDate: '2026-08-14',
|
||||||
workloadPlacement: 'end',
|
workloadPlacement: 'spread',
|
||||||
})
|
})
|
||||||
request.reply({ statusCode: 201, body: scheduleDetail })
|
request.reply({ statusCode: 201, body: scheduleDetail })
|
||||||
}).as('createSchedule')
|
}).as('createSchedule')
|
||||||
|
|
@ -279,7 +286,44 @@ describe('set scheduling', () => {
|
||||||
|
|
||||||
cy.get('[data-workload-placement]').should('be.visible').within(() => {
|
cy.get('[data-workload-placement]').should('be.visible').within(() => {
|
||||||
cy.contains('legend', 'Heavier days').should('be.visible')
|
cy.contains('legend', 'Heavier days').should('be.visible')
|
||||||
|
cy.contains('Spread evenly').should('be.visible')
|
||||||
cy.get('input[value="middle"]').should('be.checked')
|
cy.get('input[value="middle"]').should('be.checked')
|
||||||
|
cy.get('input[value="spread"]').check()
|
||||||
|
})
|
||||||
|
cy.get('form').submit()
|
||||||
|
cy.wait('@createSchedule')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('packs sparse assignment days where the user chooses', () => {
|
||||||
|
cy.intercept('GET', '**/api/sets/41', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: sparseBibleLayout,
|
||||||
|
}).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-15',
|
||||||
|
workloadPlacement: 'end',
|
||||||
|
})
|
||||||
|
request.reply({ statusCode: 201, body: scheduleDetail })
|
||||||
|
}).as('createSchedule')
|
||||||
|
|
||||||
|
cy.visit('/sets/41/schedules/new')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.wait('@layout')
|
||||||
|
cy.get('#schedule-level').select('3')
|
||||||
|
cy.get('#schedule-start-date').type('2026-08-10')
|
||||||
|
cy.get('#schedule-target-date').type('2026-08-15')
|
||||||
|
|
||||||
|
cy.get('[data-workload-placement]').should('be.visible').within(() => {
|
||||||
|
cy.contains('legend', 'Assignment days').should('be.visible')
|
||||||
|
cy.contains('Spread evenly').should('be.visible')
|
||||||
|
cy.contains('At the start').should('be.visible')
|
||||||
|
cy.contains('In the middle').should('be.visible')
|
||||||
|
cy.contains('At the end').should('be.visible')
|
||||||
|
cy.get('input[value="spread"]').should('be.checked')
|
||||||
cy.get('input[value="end"]').check()
|
cy.get('input[value="end"]').check()
|
||||||
})
|
})
|
||||||
cy.get('form').submit()
|
cy.get('form').submit()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ 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 workloadPlacementSchema = z.enum(['spread', 'start', 'middle', 'end'])
|
||||||
|
|
||||||
export const scheduleSummarySchema = z.object({
|
export const scheduleSummarySchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useSetLayoutStore } from '@/stores/setLayout'
|
||||||
|
|
||||||
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
|
type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
|
||||||
type ScheduleFieldErrors = Partial<Record<ScheduleField, string>>
|
type ScheduleFieldErrors = Partial<Record<ScheduleField, string>>
|
||||||
|
type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -26,8 +27,9 @@ const form = reactive({
|
||||||
levelId: null as number | null,
|
levelId: null as number | null,
|
||||||
startDate: '',
|
startDate: '',
|
||||||
targetDate: '',
|
targetDate: '',
|
||||||
workloadPlacement: 'middle' as WorkloadPlacement,
|
|
||||||
})
|
})
|
||||||
|
const sparseWorkloadPlacement = ref<WorkloadPlacement>('spread')
|
||||||
|
const heavyWorkloadPlacement = ref<WorkloadPlacement>('middle')
|
||||||
const fieldErrors = ref<ScheduleFieldErrors>({})
|
const fieldErrors = ref<ScheduleFieldErrors>({})
|
||||||
|
|
||||||
const levels = computed(() =>
|
const levels = computed(() =>
|
||||||
|
|
@ -38,31 +40,61 @@ const workloadPlacementOptions: Array<{
|
||||||
value: WorkloadPlacement
|
value: WorkloadPlacement
|
||||||
label: string
|
label: string
|
||||||
}> = [
|
}> = [
|
||||||
|
{ value: 'spread', label: 'Spread evenly' },
|
||||||
{ value: 'start', label: 'At the start' },
|
{ value: 'start', label: 'At the start' },
|
||||||
{ value: 'middle', label: 'In the middle' },
|
{ value: 'middle', label: 'In the middle' },
|
||||||
{ value: 'end', label: 'At the end' },
|
{ value: 'end', label: 'At the end' },
|
||||||
]
|
]
|
||||||
const showWorkloadPlacement = computed(() => {
|
const placementContext = computed<PlacementContext>(() => {
|
||||||
if (
|
if (
|
||||||
selectedLevel.value === undefined ||
|
selectedLevel.value === undefined ||
|
||||||
form.startDate === '' ||
|
form.startDate === '' ||
|
||||||
form.targetDate === '' ||
|
form.targetDate === '' ||
|
||||||
form.targetDate < form.startDate
|
form.targetDate < form.startDate
|
||||||
) {
|
) {
|
||||||
return false
|
return 'unavailable'
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.parse(`${form.startDate}T00:00:00Z`)
|
const startTime = Date.parse(`${form.startDate}T00:00:00Z`)
|
||||||
const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`)
|
const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`)
|
||||||
if (Number.isNaN(startTime) || Number.isNaN(targetTime)) {
|
if (Number.isNaN(startTime) || Number.isNaN(targetTime)) {
|
||||||
return false
|
return 'unavailable'
|
||||||
}
|
}
|
||||||
|
|
||||||
const millisecondsPerDay = 24 * 60 * 60 * 1000
|
const millisecondsPerDay = 24 * 60 * 60 * 1000
|
||||||
const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1
|
const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1
|
||||||
const assignmentCount = selectedLevel.value.elementCount
|
const assignmentCount = selectedLevel.value.elementCount
|
||||||
|
|
||||||
return assignmentCount > dayCount && assignmentCount % dayCount !== 0
|
if (assignmentCount < dayCount) {
|
||||||
|
return 'sparse'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignmentCount > dayCount && assignmentCount % dayCount !== 0) {
|
||||||
|
return 'heavy'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'balanced'
|
||||||
|
})
|
||||||
|
const showWorkloadPlacement = computed(
|
||||||
|
() => placementContext.value === 'sparse' || placementContext.value === 'heavy',
|
||||||
|
)
|
||||||
|
const selectedWorkloadPlacement = computed<WorkloadPlacement>({
|
||||||
|
get() {
|
||||||
|
if (placementContext.value === 'sparse') {
|
||||||
|
return sparseWorkloadPlacement.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return heavyWorkloadPlacement.value
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (placementContext.value === 'sparse') {
|
||||||
|
sparseWorkloadPlacement.value = value
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
heavyWorkloadPlacement.value = value
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const scheduleFormSchema = z
|
const scheduleFormSchema = z
|
||||||
|
|
@ -98,7 +130,10 @@ watch(
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
fieldErrors.value = {}
|
fieldErrors.value = {}
|
||||||
const result = scheduleFormSchema.safeParse(form)
|
const result = scheduleFormSchema.safeParse({
|
||||||
|
...form,
|
||||||
|
workloadPlacement: selectedWorkloadPlacement.value,
|
||||||
|
})
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const errors: ScheduleFieldErrors = {}
|
const errors: ScheduleFieldErrors = {}
|
||||||
for (const issue of result.error.issues) {
|
for (const issue of result.error.issues) {
|
||||||
|
|
@ -237,9 +272,15 @@ async function retry(): Promise<void> {
|
||||||
data-workload-placement
|
data-workload-placement
|
||||||
:disabled="creating"
|
:disabled="creating"
|
||||||
>
|
>
|
||||||
<legend>Heavier days</legend>
|
<legend>
|
||||||
<p>
|
{{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }}
|
||||||
Some days need one extra assignment. Choose where those days appear in the schedule.
|
</legend>
|
||||||
|
<p v-if="placementContext === 'sparse'">
|
||||||
|
Spread assignments across the schedule or keep them on consecutive days.
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Spread heavier days across the schedule or keep them together at the start, middle, or
|
||||||
|
end.
|
||||||
</p>
|
</p>
|
||||||
<div class="workload-placement__options">
|
<div class="workload-placement__options">
|
||||||
<label
|
<label
|
||||||
|
|
@ -248,7 +289,7 @@ async function retry(): Promise<void> {
|
||||||
class="workload-placement__option"
|
class="workload-placement__option"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.workloadPlacement"
|
v-model="selectedWorkloadPlacement"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="workloadPlacement"
|
name="workloadPlacement"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
|
|
@ -377,7 +418,7 @@ h1 {
|
||||||
|
|
||||||
.workload-placement__options {
|
.workload-placement__options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue