add sparse placement controls

This commit is contained in:
Yisroel Baum 2026-08-19 22:45:57 +03:00
parent 6f2c49026a
commit becd7573c6
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 78 additions and 17 deletions

View file

@ -38,10 +38,11 @@ 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 evenly across the available Attainly then distributes the selected elements across the available days.
days. When an uneven schedule has more than one assignment per day, the user When there are fewer assignments than days, the user can spread them across
can place the consecutive heavier days at the start, middle, or end of the the full range or pack them into consecutive days at the start, middle, or
schedule. end. 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.
For example, a user could choose to schedule: For example, a user could choose to schedule:

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(['start', 'middle', 'end']) export const workloadPlacementSchema = z.enum(['spread', '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,6 +14,8 @@ 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 PackedWorkloadPlacement = Exclude<WorkloadPlacement, 'spread'>
type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -26,43 +28,84 @@ 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<PackedWorkloadPlacement>('middle')
const fieldErrors = ref<ScheduleFieldErrors>({}) const fieldErrors = ref<ScheduleFieldErrors>({})
const levels = computed(() => const levels = computed(() =>
(layout.value?.levels ?? []).filter((level) => level.elementCount > 0), (layout.value?.levels ?? []).filter((level) => level.elementCount > 0),
) )
const selectedLevel = computed(() => levels.value.find((level) => level.id === form.levelId)) const selectedLevel = computed(() => levels.value.find((level) => level.id === form.levelId))
const workloadPlacementOptions: Array<{ const packedWorkloadPlacementOptions: Array<{
value: WorkloadPlacement value: PackedWorkloadPlacement
label: string label: string
}> = [ }> = [
{ 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 showWorkloadPlacement = computed(() => { const placementContext = computed<PlacementContext>(() => {
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 false return 'unavailable'
} }
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 false return 'unavailable'
} }
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
return assignmentCount > dayCount && assignmentCount % dayCount !== 0 if (assignmentCount < dayCount) {
return 'sparse'
}
if (assignmentCount > dayCount && assignmentCount % dayCount !== 0) {
return 'heavy'
}
return 'balanced'
})
const showWorkloadPlacement = computed(
() => placementContext.value === 'sparse' || placementContext.value === 'heavy',
)
const workloadPlacementOptions = computed<Array<{ value: WorkloadPlacement; label: string }>>(
() => {
if (placementContext.value === 'sparse') {
return [{ value: 'spread', label: 'Spread evenly' }, ...packedWorkloadPlacementOptions]
}
return packedWorkloadPlacementOptions
},
)
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
}
if (value !== 'spread') {
heavyWorkloadPlacement.value = value
}
},
}) })
const scheduleFormSchema = z const scheduleFormSchema = z
@ -98,7 +141,10 @@ watch(
async function submit(): Promise<void> { async function submit(): Promise<void> {
fieldErrors.value = {} fieldErrors.value = {}
const result = scheduleFormSchema.safeParse(form) const result = scheduleFormSchema.safeParse({
...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) {
@ -237,18 +283,28 @@ async function retry(): Promise<void> {
data-workload-placement data-workload-placement
:disabled="creating" :disabled="creating"
> >
<legend>Heavier days</legend> <legend>
<p> {{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }}
</legend>
<p v-if="placementContext === 'sparse'">
Spread assignments across the schedule or keep them on consecutive days.
</p>
<p v-else>
Some days need one extra assignment. Choose where those days appear in the schedule. Some days need one extra assignment. Choose where those days appear in the schedule.
</p> </p>
<div class="workload-placement__options"> <div
class="workload-placement__options"
:class="{
'workload-placement__options--sparse': placementContext === 'sparse',
}"
>
<label <label
v-for="option in workloadPlacementOptions" v-for="option in workloadPlacementOptions"
:key="option.value" :key="option.value"
class="workload-placement__option" class="workload-placement__option"
> >
<input <input
v-model="form.workloadPlacement" v-model="selectedWorkloadPlacement"
type="radio" type="radio"
name="workloadPlacement" name="workloadPlacement"
:value="option.value" :value="option.value"
@ -381,6 +437,10 @@ h1 {
gap: 0.65rem; gap: 0.65rem;
} }
.workload-placement__options--sparse {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workload-placement__option { .workload-placement__option {
display: flex; display: flex;
align-items: center; align-items: center;