diff --git a/README.md b/README.md index 71cceb3..ba8060e 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,11 @@ 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. 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. +Attainly then distributes the selected elements across the available days. +When there are fewer assignments than days, the user can spread them across +the full range or pack them into consecutive days at the start, middle, or +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: diff --git a/frontend/website/src/stores/schedules.ts b/frontend/website/src/stores/schedules.ts index 3715d0e..c6c3fde 100644 --- a/frontend/website/src/stores/schedules.ts +++ b/frontend/website/src/stores/schedules.ts @@ -6,7 +6,7 @@ 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 workloadPlacementSchema = z.enum(['spread', 'start', 'middle', 'end']) export const scheduleSummarySchema = z.object({ id: z.number().int().positive(), diff --git a/frontend/website/src/views/CreateScheduleView.vue b/frontend/website/src/views/CreateScheduleView.vue index 912268d..f0bdd1e 100644 --- a/frontend/website/src/views/CreateScheduleView.vue +++ b/frontend/website/src/views/CreateScheduleView.vue @@ -14,6 +14,8 @@ import { useSetLayoutStore } from '@/stores/setLayout' type ScheduleField = 'levelId' | 'startDate' | 'targetDate' type ScheduleFieldErrors = Partial> +type PackedWorkloadPlacement = Exclude +type PlacementContext = 'unavailable' | 'sparse' | 'heavy' | 'balanced' const route = useRoute() const router = useRouter() @@ -26,43 +28,84 @@ const form = reactive({ levelId: null as number | null, startDate: '', targetDate: '', - workloadPlacement: 'middle' as WorkloadPlacement, }) +const sparseWorkloadPlacement = ref('spread') +const heavyWorkloadPlacement = ref('middle') const fieldErrors = ref({}) 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 +const packedWorkloadPlacementOptions: Array<{ + value: PackedWorkloadPlacement label: string }> = [ { value: 'start', label: 'At the start' }, { value: 'middle', label: 'In the middle' }, { value: 'end', label: 'At the end' }, ] -const showWorkloadPlacement = computed(() => { +const placementContext = computed(() => { if ( selectedLevel.value === undefined || form.startDate === '' || form.targetDate === '' || form.targetDate < form.startDate ) { - return false + return 'unavailable' } 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 + return 'unavailable' } 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 + 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>( + () => { + if (placementContext.value === 'sparse') { + return [{ value: 'spread', label: 'Spread evenly' }, ...packedWorkloadPlacementOptions] + } + + return packedWorkloadPlacementOptions + }, +) +const selectedWorkloadPlacement = computed({ + 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 @@ -98,7 +141,10 @@ watch( async function submit(): Promise { fieldErrors.value = {} - const result = scheduleFormSchema.safeParse(form) + const result = scheduleFormSchema.safeParse({ + ...form, + workloadPlacement: selectedWorkloadPlacement.value, + }) if (!result.success) { const errors: ScheduleFieldErrors = {} for (const issue of result.error.issues) { @@ -237,18 +283,28 @@ async function retry(): Promise { data-workload-placement :disabled="creating" > - Heavier days -

+ + {{ placementContext === 'sparse' ? 'Assignment days' : 'Heavier days' }} + +

+ Spread assignments across the schedule or keep them on consecutive days. +

+

Some days need one extra assignment. Choose where those days appear in the schedule.

-
+