From 465db4a5b8f3cf418c9512c17b79fcb50cb96d9c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 08:58:17 +0300 Subject: [PATCH 1/2] test workload placement --- .../Feature/Schedule/ScheduleEndpointTest.php | 59 ++++++++ .../EvenDistributionSchedulerTest.php | 129 ++++++++++++++++++ .../Schedule/UseCases/CreateScheduleTest.php | 115 ++++++++++++++++ .../website/cypress/e2e/set-scheduling.cy.ts | 42 ++++++ 4 files changed, 345 insertions(+) create mode 100644 backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 295a245..5fed467 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -160,6 +160,65 @@ class ScheduleEndpointTest extends TestCase ->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 { $user = $this->createUser('reader@example.com'); diff --git a/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php new file mode 100644 index 0000000..f9e63a7 --- /dev/null +++ b/backend/tests/Unit/Schedule/EvenDistributionSchedulerTest.php @@ -0,0 +1,129 @@ +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 $dates + * @return list + */ + private function dailyCounts(array $dates): array + { + return array_values(array_count_values($this->formattedDates($dates))); + } + + /** + * @param list $dates + * @return list + */ + 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')); + } +} diff --git a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php index 02b7a83..47cee8d 100644 --- a/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php +++ b/backend/tests/Unit/Schedule/UseCases/CreateScheduleTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Schedule\UseCases; use App\Element\CreateElementDto; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Schedule\EvenDistributionScheduler; use App\Schedule\UseCases\CreateSchedule\CreateSchedule; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Set\CreateSetDto; @@ -108,12 +109,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, $elementRepository, $scheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); $this->assertSame('chapter', $schedule->getElementKind()); @@ -182,12 +185,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, $elementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $lessonLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-16', + workloadPlacement: 'middle', )); $this->assertSame( @@ -224,12 +229,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, $elementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $milestoneLevel->getId(), startDate: '2020-01-01', targetDate: '2030-01-01', + workloadPlacement: 'middle', )); $this->assertSame( @@ -240,6 +247,102 @@ class CreateScheduleTest extends TestCase ); } + public function test_it_defaults_heavier_days_to_the_middle(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + $lessonLevel = $this->createLevel( + $setLevelRepository, + $set, + 'lesson', + ); + + foreach (range(1, 6) as $number) { + $elementRepository->create(new CreateElementDto( + name: "Lesson {$number}", + level: $lessonLevel, + parentElement: null, + )); + } + + $schedule = (new CreateSchedule( + $setRepository, + $setLevelRepository, + $elementRepository, + new FakeScheduleRepository, + new EvenDistributionScheduler, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $lessonLevel->getId(), + startDate: '2026-08-10', + targetDate: '2026-08-13', + workloadPlacement: null, + )); + + $this->assertSame( + [ + '2026-08-10', + '2026-08-11', + '2026-08-11', + '2026-08-12', + '2026-08-12', + '2026-08-13', + ], + array_map(function ($assignment): string { + return $assignment->getScheduledDate()->format('Y-m-d'); + }, $schedule->getAssignments()), + ); + } + + public function test_it_rejects_an_unknown_workload_placement(): void + { + $user = $this->user(); + $setRepository = new FakeSetRepository; + $setLevelRepository = new FakeSetLevelRepository; + $elementRepository = new FakeElementRepository; + $set = $setRepository->create(new CreateSetDto( + name: 'Course', + creator: $user, + )); + $lessonLevel = $this->createLevel( + $setLevelRepository, + $set, + 'lesson', + ); + $elementRepository->create(new CreateElementDto( + name: 'Lesson 1', + level: $lessonLevel, + parentElement: null, + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'workloadPlacement must be start, middle, or end', + ); + + (new CreateSchedule( + $setRepository, + $setLevelRepository, + $elementRepository, + new FakeScheduleRepository, + new EvenDistributionScheduler, + ))->execute(new CreateScheduleRequest( + user: $user, + setId: $set->getId(), + levelId: $lessonLevel->getId(), + startDate: '2026-08-10', + targetDate: '2026-08-13', + workloadPlacement: 'sideways', + )); + } + public function test_it_rejects_an_unknown_set(): void { $this->expectException(NotFoundException::class); @@ -250,12 +353,14 @@ class CreateScheduleTest extends TestCase new FakeSetLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $this->user(), setId: 999, levelId: 1, startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); } @@ -276,12 +381,14 @@ class CreateScheduleTest extends TestCase new FakeSetLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: null, startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); } @@ -312,12 +419,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $bible->getId(), levelId: $moduleLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); } @@ -344,12 +453,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); } @@ -376,12 +487,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-02-30', targetDate: '2026-08-12', + workloadPlacement: 'middle', )); } @@ -410,12 +523,14 @@ class CreateScheduleTest extends TestCase $setLevelRepository, new FakeElementRepository, new FakeScheduleRepository, + new EvenDistributionScheduler, ))->execute(new CreateScheduleRequest( user: $user, setId: $set->getId(), levelId: $chapterLevel->getId(), startDate: '2026-08-12', targetDate: '2026-08-10', + workloadPlacement: 'middle', )); } diff --git a/frontend/website/cypress/e2e/set-scheduling.cy.ts b/frontend/website/cypress/e2e/set-scheduling.cy.ts index 0511405..25a5b4a 100644 --- a/frontend/website/cypress/e2e/set-scheduling.cy.ts +++ b/frontend/website/cypress/e2e/set-scheduling.cy.ts @@ -60,6 +60,13 @@ const bibleLayout = { ], } +const overloadedBibleLayout = { + ...bibleLayout, + levels: bibleLayout.levels.map((level) => + level.id === 3 ? { ...level, elementCount: 11 } : level, + ), +} + const scheduleDetail = { schedule: { id: 73, @@ -194,6 +201,7 @@ describe('set scheduling', () => { levelId: 3, startDate: '2026-08-10', targetDate: '2026-08-12', + workloadPlacement: 'middle', }) request.reply({ statusCode: 201, body: scheduleDetail }) }).as('createSchedule') @@ -244,6 +252,40 @@ describe('set scheduling', () => { ) }) + it('places heavier days where the user chooses', () => { + cy.intercept('GET', '**/api/sets/41', { + statusCode: 200, + body: overloadedBibleLayout, + }).as('layout') + cy.intercept('POST', '**/api/schedules', (request) => { + expect(request.body).to.deep.equal({ + setId: 41, + levelId: 3, + startDate: '2026-08-10', + targetDate: '2026-08-14', + workloadPlacement: 'end', + }) + request.reply({ statusCode: 201, body: scheduleDetail }) + }).as('createSchedule') + + cy.visit('/sets/41/schedules/new') + cy.wait('@me') + cy.wait('@layout') + cy.get('[data-workload-placement]').should('not.exist') + + cy.get('#schedule-level').select('3') + cy.get('#schedule-start-date').type('2026-08-10') + cy.get('#schedule-target-date').type('2026-08-14') + + cy.get('[data-workload-placement]').should('be.visible').within(() => { + cy.contains('legend', 'Heavier days').should('be.visible') + cy.get('input[value="middle"]').should('be.checked') + cy.get('input[value="end"]').check() + }) + cy.get('form').submit() + cy.wait('@createSchedule') + }) + it('validates the schedule form before submitting', () => { cy.intercept('GET', '**/api/sets/41', { statusCode: 200, From 46ba265f38b2cd78ee26056d5f4c1bd204230dad Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 09:06:56 +0300 Subject: [PATCH 2/2] add workload placement --- README.md | 5 +- .../Http/Controllers/ScheduleController.php | 3 + .../Schedule/EvenDistributionScheduler.php | 114 +++++++++++++++++ .../CreateSchedule/CreateSchedule.php | 67 +++++----- .../CreateSchedule/CreateScheduleRequest.php | 1 + backend/app/Schedule/WorkloadPlacement.php | 10 ++ frontend/website/src/stores/schedules.ts | 4 + .../website/src/views/CreateScheduleView.vue | 121 +++++++++++++++++- 8 files changed, 291 insertions(+), 34 deletions(-) create mode 100644 backend/app/Schedule/EvenDistributionScheduler.php create mode 100644 backend/app/Schedule/WorkloadPlacement.php diff --git a/README.md b/README.md index d4ded6f..71cceb3 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,10 @@ 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. +Attainly then distributes the selected elements evenly across the available +days. When an uneven schedule has more than one assignment per day, the user +can place the consecutive heavier days at the start, middle, or end of the +schedule. For example, a user could choose to schedule: diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index 33a6316..c1f8d12 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -44,6 +44,9 @@ class ScheduleController extends Controller levelId: $input->integer('levelId'), startDate: $input->string('startDate'), targetDate: $input->string('targetDate'), + workloadPlacement: $input->string( + 'workloadPlacement', + ), ), ); } catch (BadRequestException $exception) { diff --git a/backend/app/Schedule/EvenDistributionScheduler.php b/backend/app/Schedule/EvenDistributionScheduler.php new file mode 100644 index 0000000..e19caf9 --- /dev/null +++ b/backend/app/Schedule/EvenDistributionScheduler.php @@ -0,0 +1,114 @@ + + */ + 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 + */ + 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( + 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); + } +} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php index fb8635e..7c02c9c 100644 --- a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php @@ -8,8 +8,10 @@ use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; use App\Schedule\CreateScheduleAssignmentDto; use App\Schedule\CreateScheduleDto; +use App\Schedule\EvenDistributionScheduler; use App\Schedule\Schedule; use App\Schedule\ScheduleRepository; +use App\Schedule\WorkloadPlacement; use App\Set\Set; use App\Set\SetLevelRepository; use App\Set\SetRepository; @@ -23,6 +25,7 @@ class CreateSchedule private SetLevelRepository $setLevelRepository, private ElementRepository $elementRepository, private ScheduleRepository $scheduleRepository, + private EvenDistributionScheduler $evenDistributionScheduler, ) {} /** @@ -61,6 +64,9 @@ class CreateSchedule 'targetDate must not be before startDate', ); } + $workloadPlacement = $this->workloadPlacement( + $request->workloadPlacement, + ); $elements = array_values(array_filter( $this->orderedElements($set), @@ -72,10 +78,15 @@ class CreateSchedule throw new BadRequestException('level has no elements'); } - $assignments = $this->assignments( - elements: $elements, + $scheduledDates = $this->evenDistributionScheduler->scheduledDates( + assignmentCount: count($elements), startDate: $startDate, targetDate: $targetDate, + workloadPlacement: $workloadPlacement, + ); + $assignments = $this->assignments( + elements: $elements, + scheduledDates: $scheduledDates, ); return $this->scheduleRepository->create(new CreateScheduleDto( @@ -144,29 +155,21 @@ class CreateSchedule /** * @param list $elements + * @param list $scheduledDates * @return list */ private function assignments( array $elements, - DateTimeImmutable $startDate, - DateTimeImmutable $targetDate, + array $scheduledDates, ): array { - $differenceInDays = $startDate->diff($targetDate)->days; - $dayCount = $differenceInDays + 1; - $elementCount = count($elements); $assignments = []; foreach ($elements as $index => $element) { - $dayIndex = $this->dayIndex( - elementIndex: $index, - elementCount: $elementCount, - dayCount: $dayCount, - ); $assignments[] = new CreateScheduleAssignmentDto( name: $element->getName(), kind: $element->getKind(), path: $this->elementPath($element), - scheduledDate: $startDate->modify("+{$dayIndex} days"), + scheduledDate: $scheduledDates[$index], position: $index + 1, ); } @@ -190,25 +193,6 @@ class CreateSchedule 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 */ @@ -237,4 +221,23 @@ class CreateSchedule 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; + } } diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php index 70ab5db..430b1d8 100644 --- a/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php @@ -12,5 +12,6 @@ final readonly class CreateScheduleRequest public ?int $levelId, public ?string $startDate, public ?string $targetDate, + public ?string $workloadPlacement, ) {} } diff --git a/backend/app/Schedule/WorkloadPlacement.php b/backend/app/Schedule/WorkloadPlacement.php new file mode 100644 index 0000000..607f4f6 --- /dev/null +++ b/backend/app/Schedule/WorkloadPlacement.php @@ -0,0 +1,10 @@ + export type ScheduleDetail = z.infer export type AssignmentForDate = z.infer +export type WorkloadPlacement = z.infer export type CreateScheduleInput = { setId: number levelId: number startDate: string targetDate: string + workloadPlacement: WorkloadPlacement } const LIST_ERROR = "We couldn't load your schedules." diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue index deea7a2..912268d 100644 --- a/frontend/website/src/views/CreateScheduleView.vue +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -5,7 +5,11 @@ import { useRoute, useRouter } from 'vue-router' import { z } from 'zod' import AuthenticatedHeader from '@/components/AuthenticatedHeader.vue' -import { useSchedulesStore } from '@/stores/schedules' +import { + useSchedulesStore, + workloadPlacementSchema, + type WorkloadPlacement, +} from '@/stores/schedules' import { useSetLayoutStore } from '@/stores/setLayout' type ScheduleField = 'levelId' | 'startDate' | 'targetDate' @@ -22,18 +26,51 @@ const form = reactive({ levelId: null as number | null, startDate: '', targetDate: '', + workloadPlacement: 'middle' as WorkloadPlacement, }) 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 + 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 .object({ levelId: z.number('Choose a level to schedule.').int().positive(), startDate: z.string().min(1, 'Choose a start date.'), targetDate: z.string().min(1, 'Choose a target date.'), + workloadPlacement: workloadPlacementSchema, }) .superRefine((value, context) => { if (value.startDate !== '' && value.targetDate !== '' && value.targetDate < value.startDate) { @@ -194,6 +231,33 @@ async function retry(): Promise { +
+ Heavier days +

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

+
+ +
+
+