add workload placement
This commit is contained in:
parent
465db4a5b8
commit
46ba265f38
8 changed files with 291 additions and 34 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
114
backend/app/Schedule/EvenDistributionScheduler.php
Normal file
114
backend/app/Schedule/EvenDistributionScheduler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ final readonly class CreateScheduleRequest
|
|||
public ?int $levelId,
|
||||
public ?string $startDate,
|
||||
public ?string $targetDate,
|
||||
public ?string $workloadPlacement,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
10
backend/app/Schedule/WorkloadPlacement.php
Normal file
10
backend/app/Schedule/WorkloadPlacement.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Schedule;
|
||||
|
||||
enum WorkloadPlacement: string
|
||||
{
|
||||
case Start = 'start';
|
||||
case Middle = 'middle';
|
||||
case End = 'end';
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue