add workload placement

This commit is contained in:
Yisroel Baum 2026-08-19 09:06:56 +03:00
parent 465db4a5b8
commit 46ba265f38
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
8 changed files with 291 additions and 34 deletions

View file

@ -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:

View file

@ -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) {

View file

@ -0,0 +1,114 @@
<?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,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<Element> $elements
* @param list<DateTimeImmutable> $scheduledDates
* @return list<CreateScheduleAssignmentDto>
*/
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;
}
}

View file

@ -12,5 +12,6 @@ final readonly class CreateScheduleRequest
public ?int $levelId,
public ?string $startDate,
public ?string $targetDate,
public ?string $workloadPlacement,
) {}
}

View file

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

View file

@ -6,6 +6,8 @@ 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 scheduleSummarySchema = z.object({
id: z.number().int().positive(),
set: z.object({
@ -76,11 +78,13 @@ const errorResponseSchema = z.object({
export type ScheduleSummary = z.infer<typeof scheduleSummarySchema>
export type ScheduleDetail = z.infer<typeof scheduleDetailSchema>
export type AssignmentForDate = z.infer<typeof assignmentForDateSchema>
export type WorkloadPlacement = z.infer<typeof workloadPlacementSchema>
export type CreateScheduleInput = {
setId: number
levelId: number
startDate: string
targetDate: string
workloadPlacement: WorkloadPlacement
}
const LIST_ERROR = "We couldn't load your schedules."

View file

@ -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<ScheduleFieldErrors>({})
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<void> {
</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>
<button type="submit" class="primary-button" :disabled="creating">
@ -290,6 +354,57 @@ h1 {
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 {
font-size: 0.78rem;
font-weight: 800;
@ -380,5 +495,9 @@ input[aria-invalid='true'] {
.date-fields {
grid-template-columns: 1fr;
}
.workload-placement__options {
grid-template-columns: 1fr;
}
}
</style>