From 46ba265f38b2cd78ee26056d5f4c1bd204230dad Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 09:06:56 +0300 Subject: [PATCH] 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. +

+
+ +
+
+