Compare commits

..

3 commits

Author SHA1 Message Date
6c8250b3a4
Merge branch 'feature/even-scheduler' 2026-08-19 22:18:24 +03:00
46ba265f38
add workload placement 2026-08-19 09:06:56 +03:00
465db4a5b8
test workload placement 2026-08-19 08:58:17 +03:00
12 changed files with 636 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

@ -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');

View file

@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Schedule;
use App\Schedule\EvenDistributionScheduler;
use App\Schedule\WorkloadPlacement;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
class EvenDistributionSchedulerTest extends TestCase
{
public function test_it_places_one_heavier_day_at_the_requested_position(): void
{
$scheduler = new EvenDistributionScheduler;
$expectedCounts = [
WorkloadPlacement::Start->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<DateTimeImmutable> $dates
* @return list<int>
*/
private function dailyCounts(array $dates): array
{
return array_values(array_count_values($this->formattedDates($dates)));
}
/**
* @param list<DateTimeImmutable> $dates
* @return list<string>
*/
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'));
}
}

View file

@ -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',
));
}

View file

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

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>