diff --git a/README.md b/README.md index 71cceb3..fcb5ef4 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,12 @@ 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 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: diff --git a/backend/app/Schedule/EvenDistributionScheduler.php b/backend/app/Schedule/EvenDistributionScheduler.php index e19caf9..3cdb919 100644 --- a/backend/app/Schedule/EvenDistributionScheduler.php +++ b/backend/app/Schedule/EvenDistributionScheduler.php @@ -18,15 +18,7 @@ class EvenDistributionScheduler $differenceInDays = $startDate->diff($targetDate)->days; $dayCount = $differenceInDays + 1; - if ($assignmentCount < $dayCount) { - return $this->sparseDates( - assignmentCount: $assignmentCount, - dayCount: $dayCount, - startDate: $startDate, - ); - } - - return $this->dailyDates( + return $this->distributedDates( assignmentCount: $assignmentCount, dayCount: $dayCount, startDate: $startDate, @@ -37,53 +29,26 @@ class EvenDistributionScheduler /** * @return list */ - 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 - */ - private function dailyDates( + private function distributedDates( int $assignmentCount, int $dayCount, DateTimeImmutable $startDate, WorkloadPlacement $workloadPlacement, ): array { $assignmentsPerDay = intdiv($assignmentCount, $dayCount); - $heavierDayCount = $assignmentCount % $dayCount; - $heavierBlockStart = $this->heavierBlockStart( + $remainderDayCount = $assignmentCount % $dayCount; + $remainderDayIndexes = $this->remainderDayIndexes( + assignmentsPerDay: $assignmentsPerDay, dayCount: $dayCount, - heavierDayCount: $heavierDayCount, + remainderDayCount: $remainderDayCount, workloadPlacement: $workloadPlacement, ); + $remainderDays = array_fill_keys($remainderDayIndexes, true); $dates = []; for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) { $assignmentCountForDay = $assignmentsPerDay; - if ( - $dayIndex >= $heavierBlockStart - && $dayIndex < $heavierBlockStart + $heavierDayCount - ) { + if (isset($remainderDays[$dayIndex])) { $assignmentCountForDay++; } @@ -96,9 +61,68 @@ class EvenDistributionScheduler return $dates; } - private function heavierBlockStart( + /** + * @return list + */ + private function remainderDayIndexes( + int $assignmentsPerDay, 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 + */ + 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, ): int { if ($workloadPlacement === WorkloadPlacement::Start) { @@ -106,9 +130,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..fe9392a 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,38 @@ 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; } + + 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; + } } 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'; diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 5fed467..ef98f8c 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,10 +249,44 @@ 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_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 { $user = $this->createUser('reader@example.com'); diff --git a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php index f9e63a7..8459252 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,25 +54,101 @@ class EvenDistributionSchedulerTest extends TestCase ); $this->assertSame( - $expectedCounts[$placement->value], + $expectedDailyCounts, $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( - assignmentCount: 3, - startDate: $this->utc('2026-08-10'), - targetDate: $this->utc('2026-08-16'), - workloadPlacement: WorkloadPlacement::Middle, - ); + $scheduler = new EvenDistributionScheduler; + $expectedCounts = [ + 11 => [2, 2, 3, 2, 2], + 12 => [3, 2, 2, 2, 3], + 13 => [3, 2, 3, 2, 3], + ]; - $this->assertSame( - ['2026-08-10', '2026-08-13', '2026-08-16'], - $this->formattedDates($dates), - ); + foreach ($expectedCounts as $assignmentCount => $dailyCounts) { + $dates = $scheduler->scheduledDates( + assignmentCount: $assignmentCount, + startDate: $this->utc('2026-08-10'), + targetDate: $this->utc('2026-08-14'), + 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( + $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..0ade3e1 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( @@ -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'), @@ -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( diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 25a5b4a..b799db0 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') @@ -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', { statusCode: 200, body: overloadedBibleLayout, @@ -263,7 +270,7 @@ describe('set scheduling', () => { levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-14', - workloadPlacement: 'end', + workloadPlacement: 'spread', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') @@ -279,7 +286,44 @@ describe('set scheduling', () => { cy.get('[data-workload-placement]').should('be.visible').within(() => { 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="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('form').submit() 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..91fdb29 100644 --- a/frontend/website/src/views/CreateScheduleView.vue +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -14,6 +14,7 @@ import { useSetLayoutStore } from '@/stores/setLayout' type ScheduleField = 'levelId' | 'startDate' | 'targetDate' type ScheduleFieldErrors = Partial> +type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced' const route = useRoute() const router = useRouter() @@ -26,8 +27,9 @@ 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(() => @@ -38,31 +40,61 @@ const workloadPlacementOptions: Array<{ value: WorkloadPlacement label: string }> = [ + { value: 'spread', label: 'Spread evenly' }, { 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 selectedWorkloadPlacement = computed({ + 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 @@ -98,7 +130,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,9 +272,15 @@ async function retry(): Promise { data-workload-placement :disabled="creating" > - Heavier days -

- Some days need one extra assignment. Choose where those days appear in the schedule. + + {{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }} + +

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

+

+ Spread heavier days across the schedule or keep them together at the start, middle, or + end.