Compare commits

..

No commits in common. "6c8250b3a410fc951bd43ee108556f401b7ed9aa" and "80f8031ecc40a8601751dc1756038f488da1023d" have entirely different histories.

12 changed files with 34 additions and 636 deletions

View file

@ -38,10 +38,7 @@ 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 evenly across the available days.
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:

View file

@ -44,9 +44,6 @@ 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) {

View file

@ -1,114 +0,0 @@
<?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);
}
}

View file

@ -8,10 +8,8 @@ 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;
@ -25,7 +23,6 @@ class CreateSchedule
private SetLevelRepository $setLevelRepository, private SetLevelRepository $setLevelRepository,
private ElementRepository $elementRepository, private ElementRepository $elementRepository,
private ScheduleRepository $scheduleRepository, private ScheduleRepository $scheduleRepository,
private EvenDistributionScheduler $evenDistributionScheduler,
) {} ) {}
/** /**
@ -64,9 +61,6 @@ 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),
@ -78,15 +72,10 @@ class CreateSchedule
throw new BadRequestException('level has no elements'); throw new BadRequestException('level has no elements');
} }
$scheduledDates = $this->evenDistributionScheduler->scheduledDates(
assignmentCount: count($elements),
startDate: $startDate,
targetDate: $targetDate,
workloadPlacement: $workloadPlacement,
);
$assignments = $this->assignments( $assignments = $this->assignments(
elements: $elements, elements: $elements,
scheduledDates: $scheduledDates, startDate: $startDate,
targetDate: $targetDate,
); );
return $this->scheduleRepository->create(new CreateScheduleDto( return $this->scheduleRepository->create(new CreateScheduleDto(
@ -155,21 +144,29 @@ 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,
array $scheduledDates, DateTimeImmutable $startDate,
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: $scheduledDates[$index], scheduledDate: $startDate->modify("+{$dayIndex} days"),
position: $index + 1, position: $index + 1,
); );
} }
@ -193,6 +190,25 @@ 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
*/ */
@ -221,23 +237,4 @@ 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;
}
} }

View file

@ -12,6 +12,5 @@ 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,
) {} ) {}
} }

View file

@ -1,10 +0,0 @@
<?php
namespace App\Schedule;
enum WorkloadPlacement: string
{
case Start = 'start';
case Middle = 'middle';
case End = 'end';
}

View file

@ -160,65 +160,6 @@ 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');

View file

@ -1,129 +0,0 @@
<?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'));
}
}

View file

@ -5,7 +5,6 @@ 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;
@ -109,14 +108,12 @@ 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());
@ -185,14 +182,12 @@ 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(
@ -229,14 +224,12 @@ 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(
@ -247,102 +240,6 @@ 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);
@ -353,14 +250,12 @@ 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',
)); ));
} }
@ -381,14 +276,12 @@ 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',
)); ));
} }
@ -419,14 +312,12 @@ 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',
)); ));
} }
@ -453,14 +344,12 @@ 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',
)); ));
} }
@ -487,14 +376,12 @@ 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',
)); ));
} }
@ -523,14 +410,12 @@ 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',
)); ));
} }

View file

@ -60,13 +60,6 @@ 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,
@ -201,7 +194,6 @@ 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')
@ -252,40 +244,6 @@ 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,

View file

@ -6,8 +6,6 @@ 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({
@ -78,13 +76,11 @@ 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."

View file

@ -5,11 +5,7 @@ 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 { import { useSchedulesStore } from '@/stores/schedules'
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'
@ -26,51 +22,18 @@ 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) {
@ -231,33 +194,6 @@ 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">
@ -354,57 +290,6 @@ 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;
@ -495,9 +380,5 @@ 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>