From add0684610bc9f38c6ab872bd91b617e0adb03ac Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:38:34 +0300 Subject: [PATCH 01/11] test sparse schedule placement --- .../Feature/Schedule/ScheduleEndpointTest.php | 63 ++++++++++++++- .../EvenDistributionSchedulerTest.php | 77 ++++++++++++++++--- .../Schedule/UseCases/CreateScheduleTest.php | 4 +- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 5fed467..3a68664 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -196,6 +196,40 @@ class ScheduleEndpointTest extends TestCase $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 { $user = $this->createUser('reader@example.com'); @@ -215,7 +249,34 @@ class ScheduleEndpointTest extends TestCase 'targetDate' => '2026-08-14', 'workloadPlacement' => 'sideways', ])->assertBadRequest()->assertExactJson([ - 'error' => 'workloadPlacement must be start, middle, or end', + 'error' => 'workloadPlacement must be spread, start, middle, or end', + ]); + } + + public function test_it_rejects_spread_placement_for_a_dense_schedule(): void + { + $user = $this->createUser('reader@example.com'); + $set = $this->createSet($user, 'Course'); + $lessonLevel = $this->createLevel($set, 'lesson'); + $repository = app(ElementRepository::class); + + foreach (range(1, 5) as $number) { + $repository->create(new CreateElementDto( + name: "Lesson {$number}", + 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-13', + 'workloadPlacement' => 'spread', + ])->assertBadRequest()->assertExactJson([ + 'error' => 'spread placement requires fewer assignments than days', ]); } diff --git a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php index f9e63a7..2c0bf0d 100644 --- a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php +++ b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php @@ -58,19 +58,74 @@ class EvenDistributionSchedulerTest extends TestCase } } - public function test_it_preserves_sparse_distribution_across_the_full_range(): void + public function test_it_places_sparse_assignments_at_the_requested_position(): void { - $dates = (new EvenDistributionScheduler)->scheduledDates( - assignmentCount: 3, - startDate: $this->utc('2026-08-10'), - targetDate: $this->utc('2026-08-16'), - workloadPlacement: WorkloadPlacement::Middle, - ); + $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', + ], + ]; - $this->assertSame( - ['2026-08-10', '2026-08-13', '2026-08-16'], - $this->formattedDates($dates), - ); + 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( + $expectedDates[$placement->value], + $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 diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php index 47cee8d..b5059c4 100644 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -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(); $setRepository = new FakeSetRepository; @@ -192,7 +192,7 @@ class CreateScheduleTest extends TestCase levelId: $lessonLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-16', - workloadPlacement: 'middle', + workloadPlacement: null, )); $this->assertSame( From 4a9522f49dfd684ad57cc3f568f88f29eadc662a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:39:07 +0300 Subject: [PATCH 02/11] test placement validation --- backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php index b5059c4..c5f3251 100644 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -324,7 +324,7 @@ class CreateScheduleTest extends TestCase $this->expectException(BadRequestException::class); $this->expectExceptionMessage( - 'workloadPlacement must be start, middle, or end', + 'workloadPlacement must be spread, start, middle, or end', ); (new CreateSchedule( From 04de3117aed3988c856f11aeebbf1eab6356ece7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:39:53 +0300 Subject: [PATCH 03/11] test dense placement cases --- .../Unit/Schedule/EvenDistributionSchedulerTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php index 2c0bf0d..6f34eca 100644 --- a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php +++ b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php @@ -19,7 +19,8 @@ class EvenDistributionSchedulerTest extends TestCase 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( assignmentCount: 11, startDate: $this->utc('2026-08-10'), @@ -28,7 +29,7 @@ class EvenDistributionSchedulerTest extends TestCase ); $this->assertSame( - $expectedCounts[$placement->value], + $expectedDailyCounts, $this->dailyCounts($dates), ); } @@ -43,7 +44,8 @@ class EvenDistributionSchedulerTest extends TestCase 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( assignmentCount: 12, startDate: $this->utc('2026-08-10'), @@ -52,7 +54,7 @@ class EvenDistributionSchedulerTest extends TestCase ); $this->assertSame( - $expectedCounts[$placement->value], + $expectedDailyCounts, $this->dailyCounts($dates), ); } From ff8efd7f4a7e5b5d2267ab9e1825e398dbcf1e9d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:40:16 +0300 Subject: [PATCH 04/11] test single assignment placement --- backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php index c5f3251..0ade3e1 100644 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -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(); $setRepository = new FakeSetRepository; @@ -240,7 +240,7 @@ class CreateScheduleTest extends TestCase )); $this->assertSame( - '2020-01-01', + '2024-12-31', $schedule->getAssignments()[0] ->getScheduledDate() ->format('Y-m-d'), From 90dcc8665aad9cb32865cbea87870bca1b077553 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:40:43 +0300 Subject: [PATCH 05/11] add sparse workload placement --- .../Schedule/EvenDistributionScheduler.php | 24 +++++----- .../CreateSchedule/CreateSchedule.php | 46 +++++++++++++++++-- backend/app/Schedule/WorkloadPlacement.php | 1 + 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/backend/app/Schedule/EvenDistributionScheduler.php b/backend/app/Schedule/EvenDistributionScheduler.php index e19caf9..5030655 100644 --- a/backend/app/Schedule/EvenDistributionScheduler.php +++ b/backend/app/Schedule/EvenDistributionScheduler.php @@ -18,7 +18,7 @@ class EvenDistributionScheduler $differenceInDays = $startDate->diff($targetDate)->days; $dayCount = $differenceInDays + 1; - if ($assignmentCount < $dayCount) { + if ($workloadPlacement === WorkloadPlacement::Spread) { return $this->sparseDates( assignmentCount: $assignmentCount, dayCount: $dayCount, @@ -26,7 +26,7 @@ class EvenDistributionScheduler ); } - return $this->dailyDates( + return $this->groupedDates( assignmentCount: $assignmentCount, dayCount: $dayCount, startDate: $startDate, @@ -63,17 +63,17 @@ class EvenDistributionScheduler /** * @return list */ - private function dailyDates( + private function groupedDates( int $assignmentCount, int $dayCount, DateTimeImmutable $startDate, WorkloadPlacement $workloadPlacement, ): array { $assignmentsPerDay = intdiv($assignmentCount, $dayCount); - $heavierDayCount = $assignmentCount % $dayCount; - $heavierBlockStart = $this->heavierBlockStart( + $remainderDayCount = $assignmentCount % $dayCount; + $remainderBlockStart = $this->remainderBlockStart( dayCount: $dayCount, - heavierDayCount: $heavierDayCount, + remainderDayCount: $remainderDayCount, workloadPlacement: $workloadPlacement, ); $dates = []; @@ -81,8 +81,8 @@ class EvenDistributionScheduler for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) { $assignmentCountForDay = $assignmentsPerDay; if ( - $dayIndex >= $heavierBlockStart - && $dayIndex < $heavierBlockStart + $heavierDayCount + $dayIndex >= $remainderBlockStart + && $dayIndex < $remainderBlockStart + $remainderDayCount ) { $assignmentCountForDay++; } @@ -96,9 +96,9 @@ class EvenDistributionScheduler return $dates; } - private function heavierBlockStart( + private function remainderBlockStart( int $dayCount, - int $heavierDayCount, + int $remainderDayCount, WorkloadPlacement $workloadPlacement, ): int { if ($workloadPlacement === WorkloadPlacement::Start) { @@ -106,9 +106,9 @@ class EvenDistributionScheduler } if ($workloadPlacement === WorkloadPlacement::End) { - return $dayCount - $heavierDayCount; + return $dayCount - $remainderDayCount; } - return intdiv($dayCount - $heavierDayCount, 2); + return intdiv($dayCount - $remainderDayCount, 2); } } diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php index 7c02c9c..e131aa4 100644 --- a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php @@ -64,7 +64,7 @@ class CreateSchedule 'targetDate must not be before startDate', ); } - $workloadPlacement = $this->workloadPlacement( + $requestedWorkloadPlacement = $this->workloadPlacement( $request->workloadPlacement, ); @@ -78,6 +78,13 @@ class CreateSchedule throw new BadRequestException('level has no elements'); } + $workloadPlacement = $this->resolvedWorkloadPlacement( + requestedWorkloadPlacement: $requestedWorkloadPlacement, + assignmentCount: count($elements), + startDate: $startDate, + targetDate: $targetDate, + ); + $scheduledDates = $this->evenDistributionScheduler->scheduledDates( assignmentCount: count($elements), startDate: $startDate, @@ -225,19 +232,50 @@ class CreateSchedule /** * @throws BadRequestException */ - private function workloadPlacement(?string $value): WorkloadPlacement + private function workloadPlacement(?string $value): ?WorkloadPlacement { if ($value === null) { - return WorkloadPlacement::Middle; + return null; } $workloadPlacement = WorkloadPlacement::tryFrom($value); if ($workloadPlacement === null) { throw new BadRequestException( - 'workloadPlacement must be start, middle, or end', + 'workloadPlacement must be spread, start, middle, or end', ); } return $workloadPlacement; } + + /** + * @throws BadRequestException + */ + 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; + } + + if ( + $requestedWorkloadPlacement === WorkloadPlacement::Spread + && ! $isSparse + ) { + throw new BadRequestException( + 'spread placement requires fewer assignments than days', + ); + } + + return $requestedWorkloadPlacement; + } } diff --git a/backend/app/Schedule/WorkloadPlacement.php b/backend/app/Schedule/WorkloadPlacement.php index 607f4f6..3960874 100644 --- a/backend/app/Schedule/WorkloadPlacement.php +++ b/backend/app/Schedule/WorkloadPlacement.php @@ -4,6 +4,7 @@ namespace App\Schedule; enum WorkloadPlacement: string { + case Spread = 'spread'; case Start = 'start'; case Middle = 'middle'; case End = 'end'; From 6f2c49026ad6d490c5d1f82291dbcd99569d81b2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:43:35 +0300 Subject: [PATCH 06/11] test sparse placement controls --- .../website/cypress/e2e/set-scheduling.cy.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 25a5b4a..d7114d0 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -67,6 +67,13 @@ const overloadedBibleLayout = { ), } +const sparseBibleLayout = { + ...bibleLayout, + levels: bibleLayout.levels.map((level) => + level.id === 3 ? { ...level, elementCount: 4 } : level, + ), +} + const scheduleDetail = { schedule: { id: 73, @@ -201,7 +208,7 @@ describe('set scheduling', () => { levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-12', - workloadPlacement: 'middle', + workloadPlacement: 'spread', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') @@ -286,6 +293,42 @@ describe('set scheduling', () => { 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('form').submit() + cy.wait('@createSchedule') + }) + it('validates the schedule form before submitting', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, From becd7573c61b20a9a22096094437d4a5bfe5a5b9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:45:57 +0300 Subject: [PATCH 07/11] add sparse placement controls --- README.md | 9 +- frontend/website/src/stores/schedules.ts | 2 +- .../website/src/views/CreateScheduleView.vue | 84 ++++++++++++++++--- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 71cceb3..ba8060e 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ When creating a schedule, the user selects: * The start date * The target completion date -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. +Attainly then distributes the selected elements across the available days. +When there are fewer assignments than days, the user can spread them across +the full range or pack them into consecutive days at the start, middle, or +end. 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. For example, a user could choose to schedule: diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 3715d0e..c6c3fde 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -6,7 +6,7 @@ import { API_BASE } from '@/utils/apiBase' 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({ id: z.number().int().positive(), diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue index 912268d..f0bdd1e 100644 --- a/frontend/website/src/views/CreateScheduleView.vue +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -14,6 +14,8 @@ import { useSetLayoutStore } from '@/stores/setLayout' type ScheduleField = 'levelId' | 'startDate' | 'targetDate' type ScheduleFieldErrors = Partial> +type PackedWorkloadPlacement = Exclude +type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced' const route = useRoute() const router = useRouter() @@ -26,43 +28,84 @@ const form = reactive({ levelId: null as number | null, startDate: '', targetDate: '', - workloadPlacement: 'middle' as WorkloadPlacement, }) +const sparseWorkloadPlacement = ref('spread') +const heavyWorkloadPlacement = ref('middle') const fieldErrors = ref({}) const levels = computed(() => (layout.value?.levels ?? []).filter((level) => level.elementCount > 0), ) const selectedLevel = computed(() => levels.value.find((level) => level.id === form.levelId)) -const workloadPlacementOptions: Array<{ - value: WorkloadPlacement +const packedWorkloadPlacementOptions: Array<{ + value: PackedWorkloadPlacement label: string }> = [ { value: 'start', label: 'At the start' }, { value: 'middle', label: 'In the middle' }, { value: 'end', label: 'At the end' }, ] -const showWorkloadPlacement = computed(() => { +const placementContext = computed(() => { if ( selectedLevel.value === undefined || form.startDate === '' || form.targetDate === '' || form.targetDate < form.startDate ) { - return false + return 'unavailable' } 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 + return 'unavailable' } 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 + 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 workloadPlacementOptions = computed>( + () => { + if (placementContext.value === 'sparse') { + return [{ value: 'spread', label: 'Spread evenly' }, ...packedWorkloadPlacementOptions] + } + + return packedWorkloadPlacementOptions + }, +) +const selectedWorkloadPlacement = computed({ + get() { + if (placementContext.value === 'sparse') { + return sparseWorkloadPlacement.value + } + + return heavyWorkloadPlacement.value + }, + set(value) { + if (placementContext.value === 'sparse') { + sparseWorkloadPlacement.value = value + + return + } + + if (value !== 'spread') { + heavyWorkloadPlacement.value = value + } + }, }) const scheduleFormSchema = z @@ -98,7 +141,10 @@ watch( async function submit(): Promise { fieldErrors.value = {} - const result = scheduleFormSchema.safeParse(form) + const result = scheduleFormSchema.safeParse({ + ...form, + workloadPlacement: selectedWorkloadPlacement.value, + }) if (!result.success) { const errors: ScheduleFieldErrors = {} for (const issue of result.error.issues) { @@ -237,18 +283,28 @@ async function retry(): Promise { data-workload-placement :disabled="creating" > - Heavier days -

+ + {{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }} + +

+ Spread assignments across the schedule or keep them on consecutive days. +

+

Some days need one extra assignment. Choose where those days appear in the schedule.

-
+