Compare commits

..

No commits in common. "a06bb6f003eaa6c6a29e700c9e1520f5b7948937" and "6c8250b3a410fc951bd43ee108556f401b7ed9aa" have entirely different histories.

10 changed files with 91 additions and 375 deletions

View file

@ -38,12 +38,10 @@ When creating a schedule, the user selects:
* The start date * The start date
* The target completion date * The target completion date
Attainly then distributes the selected elements across the available days. Attainly then distributes the selected elements evenly across the available
When there are fewer assignments than days, the user can spread them across days. When an uneven schedule has more than one assignment per day, the user
the full range or pack them into consecutive days at the start, middle, or can place the consecutive heavier days at the start, middle, or end of the
end. When an uneven schedule has more than one assignment per day, the user schedule.
can spread the heavier days across the full range or keep them together at
the start, middle, or end.
For example, a user could choose to schedule: For example, a user could choose to schedule:

View file

@ -18,7 +18,15 @@ class EvenDistributionScheduler
$differenceInDays = $startDate->diff($targetDate)->days; $differenceInDays = $startDate->diff($targetDate)->days;
$dayCount = $differenceInDays + 1; $dayCount = $differenceInDays + 1;
return $this->distributedDates( if ($assignmentCount < $dayCount) {
return $this->sparseDates(
assignmentCount: $assignmentCount,
dayCount: $dayCount,
startDate: $startDate,
);
}
return $this->dailyDates(
assignmentCount: $assignmentCount, assignmentCount: $assignmentCount,
dayCount: $dayCount, dayCount: $dayCount,
startDate: $startDate, startDate: $startDate,
@ -29,26 +37,53 @@ class EvenDistributionScheduler
/** /**
* @return list<DateTimeImmutable> * @return list<DateTimeImmutable>
*/ */
private function distributedDates( 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 $assignmentCount,
int $dayCount, int $dayCount,
DateTimeImmutable $startDate, DateTimeImmutable $startDate,
WorkloadPlacement $workloadPlacement, WorkloadPlacement $workloadPlacement,
): array { ): array {
$assignmentsPerDay = intdiv($assignmentCount, $dayCount); $assignmentsPerDay = intdiv($assignmentCount, $dayCount);
$remainderDayCount = $assignmentCount % $dayCount; $heavierDayCount = $assignmentCount % $dayCount;
$remainderDayIndexes = $this->remainderDayIndexes( $heavierBlockStart = $this->heavierBlockStart(
assignmentsPerDay: $assignmentsPerDay,
dayCount: $dayCount, dayCount: $dayCount,
remainderDayCount: $remainderDayCount, heavierDayCount: $heavierDayCount,
workloadPlacement: $workloadPlacement, workloadPlacement: $workloadPlacement,
); );
$remainderDays = array_fill_keys($remainderDayIndexes, true);
$dates = []; $dates = [];
for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) { for ($dayIndex = 0; $dayIndex < $dayCount; $dayIndex++) {
$assignmentCountForDay = $assignmentsPerDay; $assignmentCountForDay = $assignmentsPerDay;
if (isset($remainderDays[$dayIndex])) { if (
$dayIndex >= $heavierBlockStart
&& $dayIndex < $heavierBlockStart + $heavierDayCount
) {
$assignmentCountForDay++; $assignmentCountForDay++;
} }
@ -61,68 +96,9 @@ class EvenDistributionScheduler
return $dates; return $dates;
} }
/** private function heavierBlockStart(
* @return list<int>
*/
private function remainderDayIndexes(
int $assignmentsPerDay,
int $dayCount, int $dayCount,
int $remainderDayCount, int $heavierDayCount,
WorkloadPlacement $workloadPlacement,
): array {
if ($remainderDayCount === 0) {
return [];
}
if ($workloadPlacement === WorkloadPlacement::Spread) {
return $this->spreadRemainderDayIndexes(
assignmentsPerDay: $assignmentsPerDay,
dayCount: $dayCount,
remainderDayCount: $remainderDayCount,
);
}
$blockStart = $this->remainderBlockStart(
dayCount: $dayCount,
remainderDayCount: $remainderDayCount,
workloadPlacement: $workloadPlacement,
);
return range($blockStart, $blockStart + $remainderDayCount - 1);
}
/**
* @return list<int>
*/
private function spreadRemainderDayIndexes(
int $assignmentsPerDay,
int $dayCount,
int $remainderDayCount,
): array {
if ($remainderDayCount === 1) {
if ($assignmentsPerDay === 0) {
return [0];
}
return [intdiv($dayCount - 1, 2)];
}
$dayIndexes = [];
for ($remainderIndex = 0;
$remainderIndex < $remainderDayCount;
$remainderIndex++
) {
$scaledIndex = $remainderIndex * ($dayCount - 1)
/ ($remainderDayCount - 1);
$dayIndexes[] = (int) floor($scaledIndex + 0.5);
}
return $dayIndexes;
}
private function remainderBlockStart(
int $dayCount,
int $remainderDayCount,
WorkloadPlacement $workloadPlacement, WorkloadPlacement $workloadPlacement,
): int { ): int {
if ($workloadPlacement === WorkloadPlacement::Start) { if ($workloadPlacement === WorkloadPlacement::Start) {
@ -130,9 +106,9 @@ class EvenDistributionScheduler
} }
if ($workloadPlacement === WorkloadPlacement::End) { if ($workloadPlacement === WorkloadPlacement::End) {
return $dayCount - $remainderDayCount; return $dayCount - $heavierDayCount;
} }
return intdiv($dayCount - $remainderDayCount, 2); return intdiv($dayCount - $heavierDayCount, 2);
} }
} }

View file

@ -64,7 +64,7 @@ class CreateSchedule
'targetDate must not be before startDate', 'targetDate must not be before startDate',
); );
} }
$requestedWorkloadPlacement = $this->workloadPlacement( $workloadPlacement = $this->workloadPlacement(
$request->workloadPlacement, $request->workloadPlacement,
); );
@ -78,13 +78,6 @@ class CreateSchedule
throw new BadRequestException('level has no elements'); throw new BadRequestException('level has no elements');
} }
$workloadPlacement = $this->resolvedWorkloadPlacement(
requestedWorkloadPlacement: $requestedWorkloadPlacement,
assignmentCount: count($elements),
startDate: $startDate,
targetDate: $targetDate,
);
$scheduledDates = $this->evenDistributionScheduler->scheduledDates( $scheduledDates = $this->evenDistributionScheduler->scheduledDates(
assignmentCount: count($elements), assignmentCount: count($elements),
startDate: $startDate, startDate: $startDate,
@ -232,38 +225,19 @@ class CreateSchedule
/** /**
* @throws BadRequestException * @throws BadRequestException
*/ */
private function workloadPlacement(?string $value): ?WorkloadPlacement private function workloadPlacement(?string $value): WorkloadPlacement
{ {
if ($value === null) { if ($value === null) {
return null; return WorkloadPlacement::Middle;
} }
$workloadPlacement = WorkloadPlacement::tryFrom($value); $workloadPlacement = WorkloadPlacement::tryFrom($value);
if ($workloadPlacement === null) { if ($workloadPlacement === null) {
throw new BadRequestException( throw new BadRequestException(
'workloadPlacement must be spread, start, middle, or end', 'workloadPlacement must be start, middle, or end',
); );
} }
return $workloadPlacement; return $workloadPlacement;
} }
private function resolvedWorkloadPlacement(
?WorkloadPlacement $requestedWorkloadPlacement,
int $assignmentCount,
DateTimeImmutable $startDate,
DateTimeImmutable $targetDate,
): WorkloadPlacement {
$differenceInDays = $startDate->diff($targetDate)->days;
$dayCount = $differenceInDays + 1;
$isSparse = $assignmentCount < $dayCount;
if ($requestedWorkloadPlacement === null) {
return $isSparse
? WorkloadPlacement::Spread
: WorkloadPlacement::Middle;
}
return $requestedWorkloadPlacement;
}
} }

View file

@ -4,7 +4,6 @@ namespace App\Schedule;
enum WorkloadPlacement: string enum WorkloadPlacement: string
{ {
case Spread = 'spread';
case Start = 'start'; case Start = 'start';
case Middle = 'middle'; case Middle = 'middle';
case End = 'end'; case End = 'end';

View file

@ -196,40 +196,6 @@ class ScheduleEndpointTest extends TestCase
$this->assertDatabaseCount('schedules', 1); $this->assertDatabaseCount('schedules', 1);
} }
public function test_it_packs_sparse_assignments_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, 4) 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-15',
'workloadPlacement' => 'end',
])->assertCreated();
$days = $response->json('schedule.days');
$this->assertIsArray($days);
$this->assertSame(
[0, 0, 1, 1, 1, 1],
array_map(function (array $day): int {
return count($day['assignments']);
}, $days),
);
}
public function test_it_rejects_an_unknown_workload_placement(): void public function test_it_rejects_an_unknown_workload_placement(): void
{ {
$user = $this->createUser('reader@example.com'); $user = $this->createUser('reader@example.com');
@ -249,44 +215,10 @@ class ScheduleEndpointTest extends TestCase
'targetDate' => '2026-08-14', 'targetDate' => '2026-08-14',
'workloadPlacement' => 'sideways', 'workloadPlacement' => 'sideways',
])->assertBadRequest()->assertExactJson([ ])->assertBadRequest()->assertExactJson([
'error' => 'workloadPlacement must be spread, start, middle, or end', 'error' => 'workloadPlacement must be start, middle, or end',
]); ]);
} }
public function test_it_spreads_heavier_days_across_the_full_range(): void
{
$user = $this->createUser('reader@example.com');
$set = $this->createSet($user, 'Course');
$lessonLevel = $this->createLevel($set, 'lesson');
$repository = app(ElementRepository::class);
foreach (range(1, 13) 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' => 'spread',
])->assertCreated();
$days = $response->json('schedule.days');
$this->assertIsArray($days);
$this->assertSame(
[3, 2, 3, 2, 3],
array_map(function (array $day): int {
return count($day['assignments']);
}, $days),
);
}
public function test_it_lists_only_the_users_schedules_newest_first(): void public function test_it_lists_only_the_users_schedules_newest_first(): void
{ {
$user = $this->createUser('reader@example.com'); $user = $this->createUser('reader@example.com');

View file

@ -19,8 +19,7 @@ class EvenDistributionSchedulerTest extends TestCase
WorkloadPlacement::End->value => [2, 2, 2, 2, 3], WorkloadPlacement::End->value => [2, 2, 2, 2, 3],
]; ];
foreach ($expectedCounts as $placementValue => $expectedDailyCounts) { foreach (WorkloadPlacement::cases() as $placement) {
$placement = WorkloadPlacement::from($placementValue);
$dates = $scheduler->scheduledDates( $dates = $scheduler->scheduledDates(
assignmentCount: 11, assignmentCount: 11,
startDate: $this->utc('2026-08-10'), startDate: $this->utc('2026-08-10'),
@ -29,7 +28,7 @@ class EvenDistributionSchedulerTest extends TestCase
); );
$this->assertSame( $this->assertSame(
$expectedDailyCounts, $expectedCounts[$placement->value],
$this->dailyCounts($dates), $this->dailyCounts($dates),
); );
} }
@ -44,8 +43,7 @@ class EvenDistributionSchedulerTest extends TestCase
WorkloadPlacement::End->value => [2, 2, 2, 3, 3], WorkloadPlacement::End->value => [2, 2, 2, 3, 3],
]; ];
foreach ($expectedCounts as $placementValue => $expectedDailyCounts) { foreach (WorkloadPlacement::cases() as $placement) {
$placement = WorkloadPlacement::from($placementValue);
$dates = $scheduler->scheduledDates( $dates = $scheduler->scheduledDates(
assignmentCount: 12, assignmentCount: 12,
startDate: $this->utc('2026-08-10'), startDate: $this->utc('2026-08-10'),
@ -54,102 +52,26 @@ class EvenDistributionSchedulerTest extends TestCase
); );
$this->assertSame( $this->assertSame(
$expectedDailyCounts, $expectedCounts[$placement->value],
$this->dailyCounts($dates), $this->dailyCounts($dates),
); );
} }
} }
public function test_it_spreads_heavier_days_across_the_full_range(): void public function test_it_preserves_sparse_distribution_across_the_full_range(): void
{ {
$scheduler = new EvenDistributionScheduler; $dates = (new EvenDistributionScheduler)->scheduledDates(
$expectedCounts = [ assignmentCount: 3,
11 => [2, 2, 3, 2, 2],
12 => [3, 2, 2, 2, 3],
13 => [3, 2, 3, 2, 3],
];
foreach ($expectedCounts as $assignmentCount => $dailyCounts) {
$dates = $scheduler->scheduledDates(
assignmentCount: $assignmentCount,
startDate: $this->utc('2026-08-10'), startDate: $this->utc('2026-08-10'),
targetDate: $this->utc('2026-08-14'), targetDate: $this->utc('2026-08-16'),
workloadPlacement: WorkloadPlacement::Spread, workloadPlacement: WorkloadPlacement::Middle,
);
$this->assertSame($dailyCounts, $this->dailyCounts($dates));
}
}
public function test_it_places_sparse_assignments_at_the_requested_position(): void
{
$scheduler = new EvenDistributionScheduler;
$expectedDates = [
WorkloadPlacement::Spread->value => [
'2026-08-10',
'2026-08-12',
'2026-08-13',
'2026-08-15',
],
WorkloadPlacement::Start->value => [
'2026-08-10',
'2026-08-11',
'2026-08-12',
'2026-08-13',
],
WorkloadPlacement::Middle->value => [
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
],
WorkloadPlacement::End->value => [
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
],
];
foreach (WorkloadPlacement::cases() as $placement) {
$dates = $scheduler->scheduledDates(
assignmentCount: 4,
startDate: $this->utc('2026-08-10'),
targetDate: $this->utc('2026-08-15'),
workloadPlacement: $placement,
); );
$this->assertSame( $this->assertSame(
$expectedDates[$placement->value], ['2026-08-10', '2026-08-13', '2026-08-16'],
$this->formattedDates($dates), $this->formattedDates($dates),
); );
} }
}
public function test_it_places_one_assignment_at_the_requested_position(): void
{
$scheduler = new EvenDistributionScheduler;
$expectedDates = [
WorkloadPlacement::Spread->value => '2026-08-10',
WorkloadPlacement::Start->value => '2026-08-10',
WorkloadPlacement::Middle->value => '2026-08-12',
WorkloadPlacement::End->value => '2026-08-15',
];
foreach (WorkloadPlacement::cases() as $placement) {
$dates = $scheduler->scheduledDates(
assignmentCount: 1,
startDate: $this->utc('2026-08-10'),
targetDate: $this->utc('2026-08-15'),
workloadPlacement: $placement,
);
$this->assertSame(
[$expectedDates[$placement->value]],
$this->formattedDates($dates),
);
}
}
public function test_it_distributes_even_and_single_day_schedules(): void public function test_it_distributes_even_and_single_day_schedules(): void
{ {

View file

@ -156,7 +156,7 @@ class CreateScheduleTest extends TestCase
); );
} }
public function test_it_defaults_sparse_work_to_spread_across_the_full_range(): void public function test_it_spreads_sparse_work_across_the_full_range(): void
{ {
$user = $this->user(); $user = $this->user();
$setRepository = new FakeSetRepository; $setRepository = new FakeSetRepository;
@ -192,7 +192,7 @@ class CreateScheduleTest extends TestCase
levelId: $lessonLevel->getId(), levelId: $lessonLevel->getId(),
startDate: '2026-08-10', startDate: '2026-08-10',
targetDate: '2026-08-16', targetDate: '2026-08-16',
workloadPlacement: null, workloadPlacement: 'middle',
)); ));
$this->assertSame( $this->assertSame(
@ -203,7 +203,7 @@ class CreateScheduleTest extends TestCase
); );
} }
public function test_it_places_one_element_at_the_requested_middle(): void public function test_it_places_one_element_on_the_start_date(): void
{ {
$user = $this->user(); $user = $this->user();
$setRepository = new FakeSetRepository; $setRepository = new FakeSetRepository;
@ -240,7 +240,7 @@ class CreateScheduleTest extends TestCase
)); ));
$this->assertSame( $this->assertSame(
'2024-12-31', '2020-01-01',
$schedule->getAssignments()[0] $schedule->getAssignments()[0]
->getScheduledDate() ->getScheduledDate()
->format('Y-m-d'), ->format('Y-m-d'),
@ -324,7 +324,7 @@ class CreateScheduleTest extends TestCase
$this->expectException(BadRequestException::class); $this->expectException(BadRequestException::class);
$this->expectExceptionMessage( $this->expectExceptionMessage(
'workloadPlacement must be spread, start, middle, or end', 'workloadPlacement must be start, middle, or end',
); );
(new CreateSchedule( (new CreateSchedule(

View file

@ -67,13 +67,6 @@ const overloadedBibleLayout = {
), ),
} }
const sparseBibleLayout = {
...bibleLayout,
levels: bibleLayout.levels.map((level) =>
level.id === 3 ? { ...level, elementCount: 4 } : level,
),
}
const scheduleDetail = { const scheduleDetail = {
schedule: { schedule: {
id: 73, id: 73,
@ -208,7 +201,7 @@ describe('set scheduling', () => {
levelId: 3, levelId: 3,
startDate: '2026-08-10', startDate: '2026-08-10',
targetDate: '2026-08-12', targetDate: '2026-08-12',
workloadPlacement: 'spread', workloadPlacement: 'middle',
}) })
request.reply({ statusCode: 201, body: scheduleDetail }) request.reply({ statusCode: 201, body: scheduleDetail })
}).as('createSchedule') }).as('createSchedule')
@ -259,7 +252,7 @@ describe('set scheduling', () => {
) )
}) })
it('spreads heavier days where the user chooses', () => { it('places heavier days where the user chooses', () => {
cy.intercept('GET', '**/api/sets/41', { cy.intercept('GET', '**/api/sets/41', {
statusCode: 200, statusCode: 200,
body: overloadedBibleLayout, body: overloadedBibleLayout,
@ -270,7 +263,7 @@ describe('set scheduling', () => {
levelId: 3, levelId: 3,
startDate: '2026-08-10', startDate: '2026-08-10',
targetDate: '2026-08-14', targetDate: '2026-08-14',
workloadPlacement: 'spread', workloadPlacement: 'end',
}) })
request.reply({ statusCode: 201, body: scheduleDetail }) request.reply({ statusCode: 201, body: scheduleDetail })
}).as('createSchedule') }).as('createSchedule')
@ -286,44 +279,7 @@ describe('set scheduling', () => {
cy.get('[data-workload-placement]').should('be.visible').within(() => { cy.get('[data-workload-placement]').should('be.visible').within(() => {
cy.contains('legend', 'Heavier days').should('be.visible') cy.contains('legend', 'Heavier days').should('be.visible')
cy.contains('Spread evenly').should('be.visible')
cy.get('input[value="middle"]').should('be.checked') cy.get('input[value="middle"]').should('be.checked')
cy.get('input[value="spread"]').check()
})
cy.get('form').submit()
cy.wait('@createSchedule')
})
it('packs sparse assignment days where the user chooses', () => {
cy.intercept('GET', '**/api/sets/41', {
statusCode: 200,
body: sparseBibleLayout,
}).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-15',
workloadPlacement: 'end',
})
request.reply({ statusCode: 201, body: scheduleDetail })
}).as('createSchedule')
cy.visit('/sets/41/schedules/new')
cy.wait('@me')
cy.wait('@layout')
cy.get('#schedule-level').select('3')
cy.get('#schedule-start-date').type('2026-08-10')
cy.get('#schedule-target-date').type('2026-08-15')
cy.get('[data-workload-placement]').should('be.visible').within(() => {
cy.contains('legend', 'Assignment days').should('be.visible')
cy.contains('Spread evenly').should('be.visible')
cy.contains('At the start').should('be.visible')
cy.contains('In the middle').should('be.visible')
cy.contains('At the end').should('be.visible')
cy.get('input[value="spread"]').should('be.checked')
cy.get('input[value="end"]').check() cy.get('input[value="end"]').check()
}) })
cy.get('form').submit() cy.get('form').submit()

View file

@ -6,7 +6,7 @@ import { API_BASE } from '@/utils/apiBase'
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
export const workloadPlacementSchema = z.enum(['spread', 'start', 'middle', 'end']) export const workloadPlacementSchema = z.enum(['start', 'middle', 'end'])
export const scheduleSummarySchema = z.object({ export const scheduleSummarySchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),

View file

@ -14,7 +14,6 @@ import { useSetLayoutStore } from '@/stores/setLayout'
type ScheduleField = 'levelId' | 'startDate' | 'targetDate' type ScheduleField = 'levelId' | 'startDate' | 'targetDate'
type ScheduleFieldErrors = Partial<Record<ScheduleField, string>> type ScheduleFieldErrors = Partial<Record<ScheduleField, string>>
type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -27,9 +26,8 @@ const form = reactive({
levelId: null as number | null, levelId: null as number | null,
startDate: '', startDate: '',
targetDate: '', targetDate: '',
workloadPlacement: 'middle' as WorkloadPlacement,
}) })
const sparseWorkloadPlacement = ref<WorkloadPlacement>('spread')
const heavyWorkloadPlacement = ref<WorkloadPlacement>('middle')
const fieldErrors = ref<ScheduleFieldErrors>({}) const fieldErrors = ref<ScheduleFieldErrors>({})
const levels = computed(() => const levels = computed(() =>
@ -40,61 +38,31 @@ const workloadPlacementOptions: Array<{
value: WorkloadPlacement value: WorkloadPlacement
label: string label: string
}> = [ }> = [
{ value: 'spread', label: 'Spread evenly' },
{ value: 'start', label: 'At the start' }, { value: 'start', label: 'At the start' },
{ value: 'middle', label: 'In the middle' }, { value: 'middle', label: 'In the middle' },
{ value: 'end', label: 'At the end' }, { value: 'end', label: 'At the end' },
] ]
const placementContext = computed<PlacementContext>(() => { const showWorkloadPlacement = computed(() => {
if ( if (
selectedLevel.value === undefined || selectedLevel.value === undefined ||
form.startDate === '' || form.startDate === '' ||
form.targetDate === '' || form.targetDate === '' ||
form.targetDate < form.startDate form.targetDate < form.startDate
) { ) {
return 'unavailable' return false
} }
const startTime = Date.parse(`${form.startDate}T00:00:00Z`) const startTime = Date.parse(`${form.startDate}T00:00:00Z`)
const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`) const targetTime = Date.parse(`${form.targetDate}T00:00:00Z`)
if (Number.isNaN(startTime) || Number.isNaN(targetTime)) { if (Number.isNaN(startTime) || Number.isNaN(targetTime)) {
return 'unavailable' return false
} }
const millisecondsPerDay = 24 * 60 * 60 * 1000 const millisecondsPerDay = 24 * 60 * 60 * 1000
const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1 const dayCount = Math.round((targetTime - startTime) / millisecondsPerDay) + 1
const assignmentCount = selectedLevel.value.elementCount const assignmentCount = selectedLevel.value.elementCount
if (assignmentCount < dayCount) { return assignmentCount > dayCount && assignmentCount % dayCount !== 0
return 'sparse'
}
if (assignmentCount > dayCount && assignmentCount % dayCount !== 0) {
return 'heavy'
}
return 'balanced'
})
const showWorkloadPlacement = computed(
() => placementContext.value === 'sparse' || placementContext.value === 'heavy',
)
const selectedWorkloadPlacement = computed<WorkloadPlacement>({
get() {
if (placementContext.value === 'sparse') {
return sparseWorkloadPlacement.value
}
return heavyWorkloadPlacement.value
},
set(value) {
if (placementContext.value === 'sparse') {
sparseWorkloadPlacement.value = value
return
}
heavyWorkloadPlacement.value = value
},
}) })
const scheduleFormSchema = z const scheduleFormSchema = z
@ -130,10 +98,7 @@ watch(
async function submit(): Promise<void> { async function submit(): Promise<void> {
fieldErrors.value = {} fieldErrors.value = {}
const result = scheduleFormSchema.safeParse({ const result = scheduleFormSchema.safeParse(form)
...form,
workloadPlacement: selectedWorkloadPlacement.value,
})
if (!result.success) { if (!result.success) {
const errors: ScheduleFieldErrors = {} const errors: ScheduleFieldErrors = {}
for (const issue of result.error.issues) { for (const issue of result.error.issues) {
@ -272,15 +237,9 @@ async function retry(): Promise<void> {
data-workload-placement data-workload-placement
:disabled="creating" :disabled="creating"
> >
<legend> <legend>Heavier days</legend>
{{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }} <p>
</legend> Some days need one extra assignment. Choose where those days appear in the schedule.
<p v-if="placementContext === 'sparse'">
Spread assignments across the schedule or keep them on consecutive days.
</p>
<p v-else>
Spread heavier days across the schedule or keep them together at the start, middle, or
end.
</p> </p>
<div class="workload-placement__options"> <div class="workload-placement__options">
<label <label
@ -289,7 +248,7 @@ async function retry(): Promise<void> {
class="workload-placement__option" class="workload-placement__option"
> >
<input <input
v-model="selectedWorkloadPlacement" v-model="form.workloadPlacement"
type="radio" type="radio"
name="workloadPlacement" name="workloadPlacement"
:value="option.value" :value="option.value"
@ -418,7 +377,7 @@ h1 {
.workload-placement__options { .workload-placement__options {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem; gap: 0.65rem;
} }