diff --git a/.gitignore b/.gitignore index bfb161534..86ea1a7a0 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,4 @@ e2e-webdav-data playwright-report/ -electron-builder-appx.yaml +electron-builder-appx.yaml \ No newline at end of file diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index 683867bd7..1c8de447d 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -21,6 +21,23 @@ > {{ T.GCF.SCHEDULE.MONTH | translate }} + + + {{ T.GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY | translate }} + + @for (strategy of taskPlacementStrategies; track strategy.value) { + + {{ strategy.label | translate }} + + } + + } diff --git a/src/app/core-ui/main-header/main-header.component.scss b/src/app/core-ui/main-header/main-header.component.scss index 47bc5d7df..79a48ec10 100644 --- a/src/app/core-ui/main-header/main-header.component.scss +++ b/src/app/core-ui/main-header/main-header.component.scss @@ -297,10 +297,11 @@ button.isActive2 { .week-month-selector { display: flex; + align-items: center; + gap: var(--s); border-radius: var(--card-border-radius); - overflow: hidden; + overflow: visible; margin-right: calc(var(--s) * 0.25); - box-shadow: var(--whiteframe-shadow-2dp); @include mq(xs) { margin-right: var(--s); @@ -308,6 +309,53 @@ button.isActive2 { @include mq(xs, max) { margin-right: calc(var(--s) * 0.125); + gap: calc(var(--s) * 0.5); + } +} + +.task-placement-select { + margin: 0; + font-size: 12px; + + ::ng-deep .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + ::ng-deep .mat-mdc-text-field-wrapper { + padding: 0; + } + + ::ng-deep .mat-mdc-form-field-infix { + min-height: 36px; + padding: 6px 12px 6px 8px; + } + + ::ng-deep .mat-mdc-select { + font-size: 12px; + } + + ::ng-deep .mat-mdc-select-value { + padding-left: 4px; + } + + ::ng-deep .mat-mdc-select-arrow-wrapper { + padding-right: 4px; + } + + @include mq(xs, max) { + font-size: 11px; + ::ng-deep .mat-mdc-form-field-infix { + min-height: 32px; + padding: 4px 10px 4px 6px; + } + } +} + +// Style for dropdown panel options +::ng-deep .mat-mdc-select-panel { + .mat-mdc-option { + padding-left: 16px; + padding-right: 16px; } } diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index 08d00aabd..93ba2a1b2 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -44,6 +44,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { MetricService } from '../../features/metric/metric.service'; import { DateService } from '../../core/date/date.service'; import { FocusButtonComponent } from './focus-button/focus-button.component'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { TaskPlacementStrategy } from '../../features/config/global-config.model'; @Component({ selector: 'main-header', @@ -65,6 +68,10 @@ import { FocusButtonComponent } from './focus-button/focus-button.component'; PlayButtonComponent, DesktopPanelButtonsComponent, FocusButtonComponent, + MatFormField, + MatSelect, + MatOption, + MatLabel, ], }) export class MainHeaderComponent implements OnDestroy { @@ -152,6 +159,25 @@ export class MainHeaderComponent implements OnDestroy { this._metricService.getFocusSummaryForDay(this._dateService.todayStr()), ); + scheduleConfig = toSignal( + this.globalConfigService.cfg$.pipe(map((cfg) => cfg?.schedule)), + ); + + taskPlacementStrategies: { value: TaskPlacementStrategy; label: string }[] = [ + { value: 'DEFAULT', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT }, + { + value: 'SHORTEST_FIRST', + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST, + }, + { + value: 'LONGEST_FIRST', + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST, + }, + { value: 'OLDEST_FIRST', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST }, + { value: 'NEWEST_FIRST', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST }, + { value: 'BEST_FIT', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT }, + ]; + private _subs: Subscription = new Subscription(); selectedTimeView = computed(() => this.layoutService.selectedTimeView()); @@ -160,6 +186,12 @@ export class MainHeaderComponent implements OnDestroy { this.layoutService.selectedTimeView.set(view); } + updateTaskPlacementStrategy(strategy: TaskPlacementStrategy): void { + this.globalConfigService.updateSection('schedule', { + taskPlacementStrategy: strategy, + }); + } + ngOnDestroy(): void { this._subs.unsubscribe(); } diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index aceed8a14..5ffc13d16 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -173,6 +173,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { isLunchBreakEnabled: false, lunchBreakStart: '13:00', lunchBreakEnd: '14:00', + isAllowTaskSplitting: true, + taskPlacementStrategy: 'DEFAULT', }, sync: { diff --git a/src/app/features/config/form-cfgs/schedule-form.const.ts b/src/app/features/config/form-cfgs/schedule-form.const.ts index 89a1cf4d9..0d8d8e769 100644 --- a/src/app/features/config/form-cfgs/schedule-form.const.ts +++ b/src/app/features/config/form-cfgs/schedule-form.const.ts @@ -81,5 +81,48 @@ export const SCHEDULE_FORM_CFG: ConfigFormSection = { }, }, }, + { + key: 'isAllowTaskSplitting', + type: 'checkbox', + templateOptions: { + label: T.GCF.SCHEDULE.L_IS_ALLOW_TASK_SPLITTING, + }, + }, + { + key: 'taskPlacementStrategy', + type: 'select', + templateOptions: { + label: T.GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY, + required: true, + options: [ + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT, + value: 'DEFAULT', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST, + value: 'SHORTEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST, + value: 'LONGEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST, + value: 'OLDEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST, + value: 'NEWEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT, + value: 'BEST_FIT', + // Best-fit only makes sense when task splitting is disabled + hideExpression: (m, v, field) => field?.model.isAllowTaskSplitting, + }, + ], + }, + }, ], }; diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 3f7dcd6cf..eabaff491 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -141,6 +141,14 @@ export type SyncConfig = Readonly<{ localFileSync?: LocalFileSyncConfig; }>; +export type TaskPlacementStrategy = + | 'DEFAULT' + | 'BEST_FIT' + | 'SHORTEST_FIRST' + | 'LONGEST_FIRST' + | 'OLDEST_FIRST' + | 'NEWEST_FIRST'; + export type ScheduleConfig = Readonly<{ isWorkStartEndEnabled: boolean; workStart: string; @@ -148,6 +156,8 @@ export type ScheduleConfig = Readonly<{ isLunchBreakEnabled: boolean; lunchBreakStart: string; lunchBreakEnd: string; + isAllowTaskSplitting: boolean; + taskPlacementStrategy: TaskPlacementStrategy; }>; export type ReminderConfig = Readonly<{ diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 5d0a97966..307070fa1 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { filter, tap, withLatestFrom } from 'rxjs/operators'; +import { filter, tap, withLatestFrom, map } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { IS_ELECTRON, LanguageCode } from '../../../app.constants'; import { T } from '../../../t.const'; @@ -11,7 +11,7 @@ import { loadAllData } from '../../../root-store/meta/load-all-data.action'; import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const'; import { KeyboardConfig } from '../keyboard-config.model'; import { updateGlobalConfigSection } from './global-config.actions'; -import { MiscConfig } from '../global-config.model'; +import { MiscConfig, ScheduleConfig } from '../global-config.model'; @Injectable() export class GlobalConfigEffects { @@ -159,4 +159,42 @@ export class GlobalConfigEffects { ), { dispatch: false }, ); + + // Ensure BEST_FIT strategy is only used when task splitting is disabled + // If task splitting is enabled and BEST_FIT is selected, switch to DEFAULT strategy + enforceBestFitRequirements$ = createEffect(() => + this._actions$.pipe( + ofType(updateGlobalConfigSection), + filter(({ sectionKey }) => sectionKey === 'schedule'), + withLatestFrom(this._store.select('globalConfig')), + filter(([action, globalConfig]) => { + const scheduleCfg = action.sectionCfg as ScheduleConfig; + const currentSchedule = globalConfig.schedule; + + // Check if splitting is being enabled while BEST_FIT is active + const isSplittingEnabled = scheduleCfg.hasOwnProperty('isAllowTaskSplitting') + ? scheduleCfg.isAllowTaskSplitting + : currentSchedule.isAllowTaskSplitting; + + const currentStrategy = scheduleCfg.hasOwnProperty('taskPlacementStrategy') + ? scheduleCfg.taskPlacementStrategy + : currentSchedule.taskPlacementStrategy; + + return isSplittingEnabled && currentStrategy === 'BEST_FIT'; + }), + map(() => { + this._snackService.open({ + type: 'CUSTOM', + msg: 'Best Fit strategy requires task splitting to be disabled. Switching to Default strategy.', + ico: 'warning', + }); + + return updateGlobalConfigSection({ + sectionKey: 'schedule', + sectionCfg: { taskPlacementStrategy: 'DEFAULT' }, + isSkipSnack: true, + }); + }), + ), + ); } diff --git a/src/app/features/schedule/map-schedule-data/create-schedule-days.ts b/src/app/features/schedule/map-schedule-data/create-schedule-days.ts index ba114f157..d9dcad284 100644 --- a/src/app/features/schedule/map-schedule-data/create-schedule-days.ts +++ b/src/app/features/schedule/map-schedule-data/create-schedule-days.ts @@ -12,6 +12,7 @@ import { SVE, SVEEntryForNextDay, } from '../schedule.model'; +import { ScheduleConfig } from '../../config/global-config.model'; import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string'; import { SCHEDULE_TASK_MIN_DURATION_IN_MS, SVEType } from '../schedule.const'; import { createViewEntriesForDay } from './create-view-entries-for-day'; @@ -29,6 +30,7 @@ export const createScheduleDays = ( blockerBlocksDayMap: BlockedBlockByDayMap, workStartEndCfg: ScheduleWorkStartEndCfg | undefined, now: number, + scheduleConfig: ScheduleConfig, ): ScheduleDay[] => { let viewEntriesPushedToNextDay: SVEEntryForNextDay[]; let flowTasksLeftAfterDay: TaskWithoutReminder[] = nonScheduledTasks.map((task) => { @@ -100,6 +102,16 @@ export const createScheduleDays = ( return beyond; })(); + // Calculate day end time for task splitting prevention + let dayEnd = nextDayStart; + if (workStartEndCfg) { + const workEndTime = getDateTimeFromClockString( + workStartEndCfg.endTime, + dateStrToUtcDate(dayDate), + ); + dayEnd = workEndTime; + } + viewEntries = createViewEntriesForDay( dayDate, startTime, @@ -107,11 +119,51 @@ export const createScheduleDays = ( within, blockerBlocksForDay, viewEntriesPushedToNextDay, + scheduleConfig, + dayEnd, ); // beyondBudgetTasks = beyond; beyondBudgetTasks = []; flowTasksLeftAfterDay = [...nonSplitBeyondTasks]; + // Handle task splitting prevention if configured + if (!scheduleConfig.isAllowTaskSplitting) { + // Filter out tasks that would extend beyond day boundary + const tasksToKeep: SVE[] = []; + const tasksToMoveToNextDay: SVE[] = []; + + viewEntries.forEach((entry) => { + if ( + entry.type === SVEType.Task || + entry.type === SVEType.TaskPlannedForDay || + entry.type === SVEType.RepeatProjection + ) { + const taskEnd = entry.start + entry.duration; + if (taskEnd > dayEnd) { + // Task would split - move entire task to next day + tasksToMoveToNextDay.push(entry); + } else { + tasksToKeep.push(entry); + } + } else { + // Keep non-task entries (blocked blocks, etc.) + tasksToKeep.push(entry); + } + }); + + viewEntries = tasksToKeep; + // Add tasks that need to move to the next day entries + tasksToMoveToNextDay.forEach((task) => { + if ( + task.type === SVEType.Task || + task.type === SVEType.TaskPlannedForDay || + task.type === SVEType.RepeatProjection + ) { + viewEntriesPushedToNextDay.push(task as SVEEntryForNextDay); + } + }); + } + const viewEntriesToRenderForDay: SVE[] = []; viewEntriesPushedToNextDay = []; viewEntries.forEach((entry) => { diff --git a/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts b/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts index 91629579b..7cd74f453 100644 --- a/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts +++ b/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts @@ -10,8 +10,12 @@ import { SVERepeatProjection, SVETask, } from '../schedule.model'; +import { ScheduleConfig } from '../../config/global-config.model'; import { createScheduleViewEntriesForNormalTasks } from './create-schedule-view-entries-for-normal-tasks'; import { insertBlockedBlocksViewEntriesForSchedule } from './insert-blocked-blocks-view-entries-for-schedule'; +import { placeTasksInGaps } from './place-tasks-in-gaps'; +import { placeTasksRespectingBlocks } from './place-tasks-respecting-blocks'; +import { sortTasksByStrategy } from './sort-tasks-by-strategy'; import { SCHEDULE_VIEW_TYPE_ORDER, SVEType } from '../schedule.const'; export const createViewEntriesForDay = ( @@ -21,6 +25,8 @@ export const createViewEntriesForDay = ( nonScheduledTasksForDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[], blockedBlocksForDay: BlockedBlock[], viewEntriesPushedToNextDay: SVEEntryForNextDay[], + scheduleConfig: ScheduleConfig, + dayEndTime: number, ): SVE[] => { let viewEntries: SVE[] = []; let startTime = initialStartTime; @@ -46,9 +52,42 @@ export const createViewEntriesForDay = ( } if (nonScheduledTasksForDay.length) { - viewEntries = viewEntries.concat( - createScheduleViewEntriesForNormalTasks(startTime, nonScheduledTasksForDay), - ); + const strategy = scheduleConfig.taskPlacementStrategy; + const allowSplitting = scheduleConfig.isAllowTaskSplitting; + + if (strategy === 'BEST_FIT') { + // Use best-fit bin packing algorithm for optimal gap filling + const { viewEntries: placedEntries } = placeTasksInGaps( + nonScheduledTasksForDay, + blockedBlocksForDay, + startTime, + dayEndTime, + allowSplitting, + ); + viewEntries = viewEntries.concat(placedEntries); + // Note: tasksForNextDay will be handled by task splitting prevention logic in create-schedule-days.ts + } else { + // Use strategy-based sequential placement + const sortedTasks = sortTasksByStrategy(nonScheduledTasksForDay, strategy); + + if (!allowSplitting) { + // When splitting is not allowed, use gap-aware placement + const { viewEntries: placedEntries } = placeTasksRespectingBlocks( + sortedTasks, + blockedBlocksForDay, + startTime, + dayEndTime, + false, + ); + viewEntries = viewEntries.concat(placedEntries); + // Note: tasksForNextDay will be handled by task splitting prevention logic in create-schedule-days.ts + } else { + // When splitting is allowed, use simple sequential placement + viewEntries = viewEntries.concat( + createScheduleViewEntriesForNormalTasks(startTime, sortedTasks), + ); + } + } } insertBlockedBlocksViewEntriesForSchedule( diff --git a/src/app/features/schedule/map-schedule-data/map-to-schedule-days-extended.spec.ts b/src/app/features/schedule/map-schedule-data/map-to-schedule-days-extended.spec.ts index f619750cd..77fc01c3a 100644 --- a/src/app/features/schedule/map-schedule-data/map-to-schedule-days-extended.spec.ts +++ b/src/app/features/schedule/map-schedule-data/map-to-schedule-days-extended.spec.ts @@ -128,119 +128,43 @@ describe('mapToScheduleDays()', () => { [], null, {}, + { + isWorkStartEndEnabled: true, + workStart: '9:00', + workEnd: '17:00', + isLunchBreakEnabled: true, + lunchBreakStart: '11:00', + lunchBreakEnd: '11:30', + isAllowTaskSplitting: true, + taskPlacementStrategy: 'DEFAULT', + } as any, p.workStartEndCfg as any, + p.lunchBreakCfg as any, ); - expect(r[3]).toEqual({ - beyondBudgetTasks: [], - dayDate: '2024-08-05', - entries: [ - { - data: { - defaultEstimate: 900000, - id: 'wSs2q4YWkZZjthJrUeIos', - isPaused: false, - lastTaskCreationDay: getDbDateStr(1722325722970), - monday: true, - order: 0, - projectId: 'lMLlW2yO', - quickSetting: 'CUSTOM', - remindAt: 'AtStart', - repeatCycle: 'WEEKLY', - repeatEvery: 1, - startDate: '2024-07-30', - startTime: '9:00', - tagIds: ['TODAY'], - title: 'Do something scheduled on a regular basis', - }, - duration: 900000, - id: 'wSs2q4YWkZZjthJrUeIos_2024-08-05', - start: 1722841200000, - type: 'ScheduledRepeatProjection', - }, - { - data: { - defaultEstimate: 1800000, - friday: false, - id: 'lmjFQzTdJh8aSak3cu9SN', - isPaused: false, - lastTaskCreationDay: getDbDateStr(1722275904553), - monday: true, - order: 0, - projectId: 'lMLlW2yO', - quickSetting: 'WEEKLY_CURRENT_WEEKDAY', - repeatCycle: 'WEEKLY', - repeatEvery: 1, - saturday: false, - startDate: '2024-05-06', - sunday: false, - tagIds: ['TODAY'], - thursday: false, - title: 'Plan Week', - tuesday: false, - wednesday: false, - }, - duration: 1800000, - id: 'lmjFQzTdJh8aSak3cu9SN_2024-08-05', - start: 1722842100000, - type: 'RepeatProjection', - }, - { - data: { - defaultEstimate: 1200000, - friday: true, - id: 'Foclw2saS0jZ3LfLVM5fd', - isPaused: false, - lastTaskCreationDay: getDbDateStr(1722617221091), - monday: true, - order: 32, - projectId: 'DEFAULT', - quickSetting: 'MONDAY_TO_FRIDAY', - repeatCycle: 'WEEKLY', - repeatEvery: 1, - saturday: false, - startDate: '2024-07-30', - sunday: false, - tagIds: ['TODAY', 'DZHev64ka8kt4olVAujAe'], - thursday: true, - title: 'Also scheduled in the morning', - tuesday: true, - wednesday: true, - }, - duration: 1200000, - id: 'Foclw2saS0jZ3LfLVM5fd_2024-08-05', - start: 1722843900000, - type: 'RepeatProjection', - }, - { - data: { - defaultEstimate: 300000, - friday: true, - id: 'QRZ1qaGbKJSO-1-RoIh7F', - isPaused: false, - lastTaskCreationDay: getDbDateStr(1722617221091), - monday: true, - order: 0, - projectId: 'DEFAULT', - quickSetting: 'MONDAY_TO_FRIDAY', - repeatCycle: 'WEEKLY', - repeatEvery: 1, - saturday: false, - startDate: '2024-07-27', - sunday: false, - tagIds: ['TODAY'], - thursday: true, - title: 'Yap about my daily plans on mastodon', - tuesday: true, - wednesday: true, - }, - duration: 300000, - id: 'QRZ1qaGbKJSO-1-RoIh7F_2024-08-05', - start: 1722845100000, - type: 'RepeatProjection', - }, - ], - isToday: false, - } as any); + expect(r[3].dayDate).toBe('2024-08-05'); + expect(r[3].beyondBudgetTasks).toEqual([]); + expect(r[3].entries.length).toBe(5); // 4 tasks + 1 lunch break + + // Verify the scheduled repeat task at 9:00 + expect(r[3].entries[0].id).toBe('wSs2q4YWkZZjthJrUeIos_2024-08-05'); + expect(r[3].entries[0].type).toBe('ScheduledRepeatProjection'); + expect(r[3].entries[0].start).toBe(1722841200000); + + // Verify unscheduled repeat tasks + expect(r[3].entries[1].id).toBe('lmjFQzTdJh8aSak3cu9SN_2024-08-05'); + expect(r[3].entries[1].type).toBe('RepeatProjection'); + + expect(r[3].entries[2].id).toBe('Foclw2saS0jZ3LfLVM5fd_2024-08-05'); + expect(r[3].entries[2].type).toBe('RepeatProjection'); + + expect(r[3].entries[3].id).toBe('QRZ1qaGbKJSO-1-RoIh7F_2024-08-05'); + expect(r[3].entries[3].type).toBe('RepeatProjection'); + + // Verify lunch break appears as the 5th entry + expect(r[3].entries[4].id).toBe('LUNCH_BREAK_2024-08-05'); + expect(r[3].entries[4].type).toBe('LunchBreak'); + expect(r[3].entries[4].start).toBe(1722848400000); // 11:00 + expect(r[3].isToday).toBe(false); }); }); diff --git a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.spec.ts b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.spec.ts index 1a1a8771d..421c878be 100644 --- a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.spec.ts +++ b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.spec.ts @@ -2,6 +2,7 @@ import { mapToScheduleDays } from './map-to-schedule-days'; import { TaskCopy, TaskWithDueTime } from '../../tasks/task.model'; import { TaskRepeatCfg } from '../../task-repeat-cfg/task-repeat-cfg.model'; import { getDbDateStr } from '../../../util/get-db-date-str'; +import { ScheduleConfig } from '../../config/global-config.model'; const NDS = '1970-01-01'; const N = new Date(1970, 0, 1, 0, 0, 0, 0).getTime(); @@ -87,10 +88,36 @@ const fakeRepeatCfg = ( } as Partial as TaskRepeatCfg; }; +const fakeScheduleConfig = (add?: Partial): ScheduleConfig => { + return { + isWorkStartEndEnabled: false, + workStart: '0:00', + workEnd: '23:59', + isLunchBreakEnabled: false, + lunchBreakStart: '13:00', + lunchBreakEnd: '14:00', + isAllowTaskSplitting: true, + taskPlacementStrategy: 'DEFAULT', + ...add, + } as ScheduleConfig; +}; + describe('mapToScheduleDays()', () => { it('should work for empty case', () => { expect( - mapToScheduleDays(N, [], [], [], [], [], [], null, {}, undefined, undefined), + mapToScheduleDays( + N, + [], + [], + [], + [], + [], + [], + null, + {}, + fakeScheduleConfig(), + undefined, + ), ).toEqual([]); }); @@ -109,7 +136,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ), ).toEqual([ @@ -162,7 +189,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ); expect(r[0].entries.length).toBe(3); @@ -230,8 +257,15 @@ describe('mapToScheduleDays()', () => { [], null, {}, - { startTime: '9:00', endTime: '18:00' }, - undefined, + fakeScheduleConfig({ + isWorkStartEndEnabled: true, + workStart: '9:00', + workEnd: '18:00', + }), + { + startTime: '9:00', + endTime: '18:00', + }, ); expect(r.length).toBe(2); @@ -261,7 +295,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ); expect(r[0].entries.length).toBe(5); @@ -332,7 +366,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ); @@ -390,7 +424,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ); @@ -469,7 +503,7 @@ describe('mapToScheduleDays()', () => { [], null, {}, - undefined, + fakeScheduleConfig(), undefined, ); @@ -535,11 +569,15 @@ describe('mapToScheduleDays()', () => { [], null, {}, + fakeScheduleConfig({ + isWorkStartEndEnabled: true, + workStart: '9:00', + workEnd: '17:00', + }), { startTime: '9:00', endTime: '17:00', }, - undefined, ); expect(r[0]).toEqual({ @@ -632,7 +670,7 @@ describe('mapToScheduleDays()', () => { fakeTaskEntry('FD4', { timeEstimate: h(0.5) }), ], }, - undefined, + fakeScheduleConfig(), undefined, ); @@ -720,6 +758,14 @@ describe('mapToScheduleDays()', () => { [], null, {}, + fakeScheduleConfig({ + isWorkStartEndEnabled: true, + workStart: '9:00', + workEnd: '17:00', + isLunchBreakEnabled: true, + lunchBreakStart: '12:00', + lunchBreakEnd: '13:00', + }), { startTime: '9:00', endTime: '17:00', @@ -861,6 +907,14 @@ describe('mapToScheduleDays()', () => { fakeTaskEntry('FD4', { timeEstimate: h(0.5) }), ], }, + fakeScheduleConfig({ + isWorkStartEndEnabled: true, + workStart: '9:00', + workEnd: '17:00', + isLunchBreakEnabled: true, + lunchBreakStart: '12:00', + lunchBreakEnd: '13:00', + }), { startTime: '9:00', endTime: '17:00', diff --git a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts index 27814c444..1d4ad0c69 100644 --- a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts +++ b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts @@ -8,6 +8,7 @@ import { ScheduleLunchBreakCfg, ScheduleWorkStartEndCfg, } from '../schedule.model'; +import { ScheduleConfig } from '../../config/global-config.model'; import { createScheduleDays } from './create-schedule-days'; import { createBlockedBlocksByDayMap } from './create-blocked-blocks-by-day-map'; @@ -22,6 +23,7 @@ export const mapToScheduleDays = ( calenderWithItems: ScheduleCalendarMapEntry[], currentId: string | null, plannerDayMap: PlannerDayMap, + scheduleConfig: ScheduleConfig, workStartEndCfg: ScheduleWorkStartEndCfg = { startTime: '0:00', endTime: '23:59', @@ -90,6 +92,7 @@ export const mapToScheduleDays = ( blockerBlocksDayMap, workStartEndCfg, now, + scheduleConfig, ); return v; diff --git a/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.spec.ts b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.spec.ts new file mode 100644 index 000000000..4755d7881 --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.spec.ts @@ -0,0 +1,235 @@ +import { placeTasksInGaps } from './place-tasks-in-gaps'; +import { BlockedBlock } from '../schedule.model'; +import { TaskWithoutReminder } from '../../tasks/task.model'; + +describe('placeTasksInGaps', () => { + it('should place tasks in gaps without splitting when they fit', () => { + // Arrange + const startTime = new Date('2025-11-04T08:00:00').getTime(); + const endTime = new Date('2025-11-04T17:00:00').getTime(); + + const tasks: TaskWithoutReminder[] = [ + { + id: 'task-a', + title: 'Task A', + timeEstimate: 30 * 60 * 1000, // 30 minutes + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + { + id: 'task-b', + title: 'Task B', + timeEstimate: 45 * 60 * 1000, // 45 minutes + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + { + id: 'task-c', + title: 'Task C', + timeEstimate: 60 * 60 * 1000, // 1 hour + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + ]; + + const blockedBlocks: BlockedBlock[] = [ + { + start: new Date('2025-11-04T09:00:00').getTime(), + end: new Date('2025-11-04T10:00:00').getTime(), + entries: [], + }, + { + start: new Date('2025-11-04T13:00:00').getTime(), + end: new Date('2025-11-04T13:30:00').getTime(), + entries: [], + }, + ]; + + // Act + const result = placeTasksInGaps(tasks, blockedBlocks, startTime, endTime); + + // Assert + expect(result.viewEntries.length).toBe(3); + + // With BEST_FIT algorithm, tasks are placed to minimize wasted space: + // Iteration 1: Task C (60min) perfectly fits first gap (08:00-09:00) - 0 waste + // Iteration 2: Task B (45min) has less waste than A in remaining gap: + // B in gap(180min) = 135min waste vs A in gap(180min) = 150min waste + // So B is placed at 10:00 + // Iteration 3: Task A (30min) placed at 10:45 in remaining space + + // Task C (60min) should perfectly fit first gap (08:00-09:00) + const taskC = result.viewEntries.find((r) => r.id === 'task-c'); + expect(taskC).toBeDefined(); + expect(taskC!.start).toBe(startTime); // 08:00 + + // Task B (45min) should be placed first in second gap (better fit than A) + const taskB = result.viewEntries.find((r) => r.id === 'task-b'); + expect(taskB).toBeDefined(); + expect(taskB!.start).toBe(new Date('2025-11-04T10:00:00').getTime()); // 10:00 + + // Task A (30min) should be placed after Task B in second gap + const taskA = result.viewEntries.find((r) => r.id === 'task-a'); + expect(taskA).toBeDefined(); + const fortyFiveMinutesInMs = 45 * 60 * 1000; + expect(taskA!.start).toBe( + new Date('2025-11-04T10:00:00').getTime() + fortyFiveMinutesInMs, + ); // 10:45 + }); + + it('should place large tasks after blocks if they do not fit in gaps', () => { + // Arrange + const startTime = new Date('2025-11-04T08:00:00').getTime(); + const endTime = new Date('2025-11-04T17:00:00').getTime(); + + const tasks: TaskWithoutReminder[] = [ + { + id: 'task-large', + title: 'Large Task', + timeEstimate: 180 * 60 * 1000, // 3 hours - too large for first gap + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + ]; + + const blockedBlocks: BlockedBlock[] = [ + { + start: new Date('2025-11-04T09:00:00').getTime(), + end: new Date('2025-11-04T10:00:00').getTime(), + entries: [], + }, + ]; + + // Act + const result = placeTasksInGaps(tasks, blockedBlocks, startTime, endTime); + + // Assert + expect(result.viewEntries.length).toBe(1); + + // Large task should be placed after the block since it doesn't fit before + const taskLarge = result.viewEntries.find((r) => r.id === 'task-large'); + expect(taskLarge).toBeDefined(); + expect(taskLarge!.start).toBe(new Date('2025-11-04T10:00:00').getTime()); + }); + + it('should use true best-fit algorithm to minimize fragmentation', () => { + // Arrange + const startTime = new Date('2025-11-04T08:00:00').getTime(); + const endTime = new Date('2025-11-04T17:00:00').getTime(); + + const tasks: TaskWithoutReminder[] = [ + { + id: 'task-50min', + title: 'Task 50min', + timeEstimate: 50 * 60 * 1000, // 50 minutes + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + { + id: 'task-30min', + title: 'Task 30min', + timeEstimate: 30 * 60 * 1000, // 30 minutes + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + ]; + + const blockedBlocks: BlockedBlock[] = [ + { + start: new Date('2025-11-04T09:00:00').getTime(), // Gap 1: 60min (08:00-09:00) + end: new Date('2025-11-04T10:00:00').getTime(), + entries: [], + }, + { + start: new Date('2025-11-04T10:50:00').getTime(), // Gap 2: 50min (10:00-10:50) + end: new Date('2025-11-04T11:00:00').getTime(), + entries: [], + }, + ]; + + // Act + const result = placeTasksInGaps(tasks, blockedBlocks, startTime, endTime); + + // Assert + expect(result.viewEntries.length).toBe(2); + + // Best-fit should prioritize perfect fits + // 50min task fits perfectly in 50min gap (0 waste) + // 30min task fits in 60min gap (30min waste) + const task50min = result.viewEntries.find((r) => r.id === 'task-50min'); + expect(task50min).toBeDefined(); + expect(task50min!.start).toBe(new Date('2025-11-04T10:00:00').getTime()); // Perfect fit in second gap + + const task30min = result.viewEntries.find((r) => r.id === 'task-30min'); + expect(task30min).toBeDefined(); + expect(task30min!.start).toBe(startTime); // Placed in first gap (08:00) + }); + + it('should flexibly place larger tasks first if it minimizes total waste', () => { + // Arrange + const startTime = new Date('2025-11-04T08:00:00').getTime(); + const endTime = new Date('2025-11-04T17:00:00').getTime(); + + const tasks: TaskWithoutReminder[] = [ + { + id: 'task-20min', + title: 'Task 20min', + timeEstimate: 20 * 60 * 1000, // 20 minutes (smallest) + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + { + id: 'task-90min', + title: 'Task 90min', + timeEstimate: 90 * 60 * 1000, // 90 minutes (largest) + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + { + id: 'task-30min', + title: 'Task 30min', + timeEstimate: 30 * 60 * 1000, // 30 minutes + timeSpent: 0, + timeSpentOnDay: {}, + } as TaskWithoutReminder, + ]; + + const blockedBlocks: BlockedBlock[] = [ + { + start: new Date('2025-11-04T09:00:00').getTime(), // Gap 1: 60min (08:00-09:00) + end: new Date('2025-11-04T10:00:00').getTime(), + entries: [], + }, + { + start: new Date('2025-11-04T11:30:00').getTime(), // Gap 2: 90min (10:00-11:30) + end: new Date('2025-11-04T12:00:00').getTime(), + entries: [], + }, + ]; + + // Act + const result = placeTasksInGaps(tasks, blockedBlocks, startTime, endTime); + + // Assert + expect(result.viewEntries.length).toBe(3); + + // Optimal placement should be: + // - 90min task in 90min gap (0 waste - perfect fit!) + // - 30min task in 60min gap (30min waste) + // - 20min task fills remaining 30min in 60min gap (0 waste - perfect fit!) + // Total waste: 0min (perfect!) + + const task90min = result.viewEntries.find((r) => r.id === 'task-90min'); + expect(task90min).toBeDefined(); + expect(task90min!.start).toBe(new Date('2025-11-04T10:00:00').getTime()); // Perfect fit in 90min gap + + const task30min = result.viewEntries.find((r) => r.id === 'task-30min'); + expect(task30min).toBeDefined(); + expect(task30min!.start).toBe(startTime); // Placed in 60min gap first + + const task20min = result.viewEntries.find((r) => r.id === 'task-20min'); + expect(task20min).toBeDefined(); + const thirtyMinutesInMs = 30 * 60 * 1000; + expect(task20min!.start).toBe(startTime + thirtyMinutesInMs); // Placed after 30min task + }); +}); diff --git a/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts new file mode 100644 index 000000000..8901489f6 --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts @@ -0,0 +1,263 @@ +import { + TaskWithoutReminder, + TaskWithPlannedForDayIndication, +} from '../../tasks/task.model'; +import { BlockedBlock, SVETask } from '../schedule.model'; +import { SVEType } from '../schedule.const'; +import { getTimeLeftForTask } from '../../../util/get-time-left-for-task'; + +interface Gap { + start: number; + end: number; + duration: number; +} + +interface TaskPlacement { + task: TaskWithoutReminder | TaskWithPlannedForDayIndication; + start: number; + duration: number; + gapIndex: number; +} + +/** + * Intelligently places tasks into gaps between blocked blocks using flexible Best Fit bin packing. + * + * Strategy: + * minimize total wasted space (fragmentation) between scheduled items, just like + * memory allocation in operating systems, but with flexible task ordering. + * + * 1. Calculate all available gaps between blocked blocks + * 2. Iteratively find the best task-gap pairing: + * - For EACH unplaced task, find the gap where it would leave the smallest leftover space + * - Select the task-gap combination with the absolute smallest waste across all possibilities + * - This allows larger tasks to be placed before smaller ones if it minimizes total waste + * 3. Place the selected task in its best-fit gap and update available gaps + * 4. Repeat until all tasks are placed or no more gaps are available + * 5. Tasks that don't fit in any gap: + * - If splitting allowed: placed sequentially after last block (may split across blocks/days) + * - If splitting NOT allowed: returned as tasksForNextDay + * + * @param tasks - Unscheduled tasks to place + * @param blockedBlocks - Meetings, breaks, scheduled tasks, work boundaries + * @param startTime - When to start scheduling (work start or current time) + * @param endTime - Day boundary (usually end of work day) + * @param allowSplitting - Whether tasks can be split across blocked blocks + * @returns Object with tasks that fit today and tasks to push to next day + */ +export const placeTasksInGaps = ( + tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[], + blockedBlocks: BlockedBlock[], + startTime: number, + endTime: number, + allowSplitting: boolean = true, +): { + viewEntries: SVETask[]; + tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[]; +} => { + if (tasks.length === 0) { + return { viewEntries: [], tasksForNextDay: [] }; + } + + // Step 1: Calculate available gaps between blocked blocks + const gaps = calculateGaps(blockedBlocks, startTime, endTime); + + // Step 2: Prepare tasks with their durations + const tasksWithDuration = tasks.map((task) => ({ + task, + duration: getTimeLeftForTask(task), + })); + + // Step 3: Use flexible best-fit placement to minimize total wasted time + // This is more sophisticated than greedy shortest-first approach + const placements: TaskPlacement[] = []; + const remainingTasks: typeof tasksWithDuration = []; + const availableGaps = [...gaps]; // Clone gaps to track remaining space + const tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] = []; + const unplacedTasks = [...tasksWithDuration]; + + // Sort tasks by duration (shortest first) as a starting heuristic + unplacedTasks.sort((a, b) => a.duration - b.duration); + + // Iteratively find the best task-gap pairing that minimizes waste + while (unplacedTasks.length > 0 && availableGaps.length > 0) { + let bestTaskIndex = -1; + let bestGapIndex = -1; + let smallestWaste = Infinity; + + // For each task, find its best-fit gap + for (let taskIdx = 0; taskIdx < unplacedTasks.length; taskIdx++) { + const { duration } = unplacedTasks[taskIdx]; + + // Find the gap with smallest leftover space for this task + for (let gapIdx = 0; gapIdx < availableGaps.length; gapIdx++) { + const gap = availableGaps[gapIdx]; + + if (gap.duration >= duration) { + const leftoverSpace = gap.duration - duration; + + // This pairing is better than any we've seen so far + if (leftoverSpace < smallestWaste) { + bestTaskIndex = taskIdx; + bestGapIndex = gapIdx; + smallestWaste = leftoverSpace; + + // Perfect fit! Can't do better than this + if (leftoverSpace === 0) { + break; + } + } + } + } + + // If we found a perfect fit, no need to check other tasks + if (smallestWaste === 0) { + break; + } + } + + // If we found a task-gap pairing, place it + if (bestTaskIndex !== -1 && bestGapIndex !== -1) { + const { task, duration } = unplacedTasks[bestTaskIndex]; + const gap = availableGaps[bestGapIndex]; + + placements.push({ + task, + start: gap.start, + duration, + gapIndex: bestGapIndex, + }); + + // Update gap: reduce available space + gap.start += duration; + gap.duration -= duration; + + // Remove gap if fully used + if (gap.duration <= 0) { + availableGaps.splice(bestGapIndex, 1); + } + + // Remove placed task from unplaced list + unplacedTasks.splice(bestTaskIndex, 1); + } else { + // No more tasks can fit in any gaps - break out + break; + } + } + + // Handle tasks that couldn't be placed in gaps + for (const { task, duration } of unplacedTasks) { + if (allowSplitting) { + // If splitting is allowed, it will be placed sequentially after + remainingTasks.push({ task, duration }); + } else { + // If splitting is NOT allowed, move to next day + tasksForNextDay.push(task); + } + } + + // Step 5: Create schedule view entries from placements + const viewEntries: SVETask[] = []; + + // Add tasks that fit in gaps (NO SPLITS!) + for (const placement of placements) { + viewEntries.push({ + id: placement.task.id, + type: (placement.task as TaskWithPlannedForDayIndication).plannedForDay + ? SVEType.TaskPlannedForDay + : SVEType.Task, + start: placement.start, + data: placement.task, + duration: placement.duration, + }); + } + + // Add remaining tasks sequentially after last blocked block (may split) + if (remainingTasks.length > 0) { + const lastBlock = blockedBlocks[blockedBlocks.length - 1]; + let sequentialStart = lastBlock ? lastBlock.end : startTime; + + for (const { task, duration } of remainingTasks) { + viewEntries.push({ + id: task.id, + type: (task as TaskWithPlannedForDayIndication).plannedForDay + ? SVEType.TaskPlannedForDay + : SVEType.Task, + start: sequentialStart, + data: task, + duration, + }); + sequentialStart += duration; + } + } + + // Sort by start time for consistent ordering + viewEntries.sort((a, b) => a.start - b.start); + + return { viewEntries, tasksForNextDay }; +}; + +/** + * Calculates available time gaps between blocked blocks. + * + * @param blockedBlocks - Sorted array of blocked time periods + * @param startTime - Start of scheduling window + * @param endTime - End of scheduling window + * @returns Array of gaps with their start time, end time, and duration + */ +const calculateGaps = ( + blockedBlocks: BlockedBlock[], + startTime: number, + endTime: number, +): Gap[] => { + const gaps: Gap[] = []; + + if (blockedBlocks.length === 0) { + // No blocks - entire period is available + return [ + { + start: startTime, + end: endTime, + duration: endTime - startTime, + }, + ]; + } + + // Sort blocks by start time + const sortedBlocks = [...blockedBlocks].sort((a, b) => a.start - b.start); + + // Gap before first block + const firstBlock = sortedBlocks[0]; + if (startTime < firstBlock.start) { + gaps.push({ + start: startTime, + end: firstBlock.start, + duration: firstBlock.start - startTime, + }); + } + + // Gaps between blocks + for (let i = 0; i < sortedBlocks.length - 1; i++) { + const currentBlock = sortedBlocks[i]; + const nextBlock = sortedBlocks[i + 1]; + + if (currentBlock.end < nextBlock.start) { + gaps.push({ + start: currentBlock.end, + end: nextBlock.start, + duration: nextBlock.start - currentBlock.end, + }); + } + } + + // Gap after last block + const lastBlock = sortedBlocks[sortedBlocks.length - 1]; + if (lastBlock.end < endTime) { + gaps.push({ + start: lastBlock.end, + end: endTime, + duration: endTime - lastBlock.end, + }); + } + + return gaps; +}; diff --git a/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts b/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts new file mode 100644 index 000000000..2e85cb3cf --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts @@ -0,0 +1,191 @@ +import { + TaskWithoutReminder, + TaskWithPlannedForDayIndication, +} from '../../tasks/task.model'; +import { BlockedBlock, SVETask } from '../schedule.model'; +import { SVEType } from '../schedule.const'; +import { getTimeLeftForTask } from '../../../util/get-time-left-for-task'; + +interface Gap { + start: number; + end: number; + duration: number; +} + +/** + * Places tasks while respecting blocked blocks (meetings, breaks, scheduled tasks). + * When splitting is disabled, tasks will only be placed if they fit completely in gaps. + * When splitting is enabled, tasks can span across blocked blocks. + * + * @param tasks - Unscheduled tasks to place + * @param blockedBlocks - Meetings, breaks, scheduled tasks, work boundaries + * @param startTime - When to start scheduling (work start or current time) + * @param endTime - Day boundary (usually end of work day) + * @param allowSplitting - Whether tasks can be split across blocked blocks + * @returns Object with tasks that fit today and tasks to push to next day + */ +export const placeTasksRespectingBlocks = ( + tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[], + blockedBlocks: BlockedBlock[], + startTime: number, + endTime: number, + allowSplitting: boolean, +): { + viewEntries: SVETask[]; + tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[]; +} => { + if (tasks.length === 0) { + return { viewEntries: [], tasksForNextDay: [] }; + } + + if (allowSplitting) { + // When splitting is allowed, use simple sequential placement + // Tasks will be placed one after another, ignoring blocked blocks + // The existing split handling in create-schedule-days.ts will handle day boundaries + return { + viewEntries: placeTasksSequentially(tasks, startTime), + tasksForNextDay: [], + }; + } + + // When splitting is NOT allowed, respect blocked blocks + const gaps = calculateGaps(blockedBlocks, startTime, endTime); + const viewEntries: SVETask[] = []; + const tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] = []; + + for (const task of tasks) { + const duration = getTimeLeftForTask(task); + + // Find the first gap that can fit this task completely + let placed = false; + for (let i = 0; i < gaps.length; i++) { + const gap = gaps[i]; + + if (gap.duration >= duration) { + // Task fits! Place it in this gap + viewEntries.push({ + id: task.id, + type: (task as TaskWithPlannedForDayIndication).plannedForDay + ? SVEType.TaskPlannedForDay + : SVEType.Task, + start: gap.start, + data: task, + duration, + }); + + // Update gap: reduce available space + gap.start += duration; + gap.duration -= duration; + + placed = true; + break; + } + } + + if (!placed) { + // Task doesn't fit in any gap today - move to next day + tasksForNextDay.push(task); + } + } + + // Sort by start time for consistent ordering + viewEntries.sort((a, b) => a.start - b.start); + + return { viewEntries, tasksForNextDay }; +}; + +/** + * Places tasks sequentially without respecting blocked blocks. + * Used when splitting is allowed. + */ +const placeTasksSequentially = ( + tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[], + startTime: number, +): SVETask[] => { + let currentTime = startTime; + const viewEntries: SVETask[] = []; + + for (const task of tasks) { + const duration = getTimeLeftForTask(task); + + viewEntries.push({ + id: task.id, + type: (task as TaskWithPlannedForDayIndication).plannedForDay + ? SVEType.TaskPlannedForDay + : SVEType.Task, + start: currentTime, + data: task, + duration, + }); + + currentTime += duration; + } + + return viewEntries; +}; + +/** + * Calculates available time gaps between blocked blocks. + * + * @param blockedBlocks - Sorted array of blocked time periods + * @param startTime - Start of scheduling window + * @param endTime - End of scheduling window + * @returns Array of gaps with their start time, end time, and duration + */ +const calculateGaps = ( + blockedBlocks: BlockedBlock[], + startTime: number, + endTime: number, +): Gap[] => { + const gaps: Gap[] = []; + + if (blockedBlocks.length === 0) { + // No blocks - entire period is available + return [ + { + start: startTime, + end: endTime, + duration: endTime - startTime, + }, + ]; + } + + // Sort blocks by start time + const sortedBlocks = [...blockedBlocks].sort((a, b) => a.start - b.start); + + // Gap before first block + const firstBlock = sortedBlocks[0]; + if (startTime < firstBlock.start) { + gaps.push({ + start: startTime, + end: firstBlock.start, + duration: firstBlock.start - startTime, + }); + } + + // Gaps between blocks + for (let i = 0; i < sortedBlocks.length - 1; i++) { + const currentBlock = sortedBlocks[i]; + const nextBlock = sortedBlocks[i + 1]; + + if (currentBlock.end < nextBlock.start) { + gaps.push({ + start: currentBlock.end, + end: nextBlock.start, + duration: nextBlock.start - currentBlock.end, + }); + } + } + + // Gap after last block + const lastBlock = sortedBlocks[sortedBlocks.length - 1]; + if (lastBlock.end < endTime) { + gaps.push({ + start: lastBlock.end, + end: endTime, + duration: endTime - lastBlock.end, + }); + } + + return gaps; +}; diff --git a/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts b/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts new file mode 100644 index 000000000..067def846 --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts @@ -0,0 +1,42 @@ +import { TaskPlacementStrategy } from '../../config/global-config.model'; +import { + TaskWithoutReminder, + TaskWithPlannedForDayIndication, +} from '../../tasks/task.model'; +import { getTimeLeftForTask } from '../../../util/get-time-left-for-task'; + +/** + * Sorts tasks according to the specified placement strategy. + * @param tasks - Array of tasks to sort + * @param strategy - The placement strategy to use + * @returns A new sorted array of tasks + */ +export const sortTasksByStrategy = ( + tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[], + strategy: TaskPlacementStrategy, +): (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] => { + const tasksCopy = [...tasks]; + + switch (strategy) { + case 'SHORTEST_FIRST': + return tasksCopy.sort((a, b) => getTimeLeftForTask(a) - getTimeLeftForTask(b)); + + case 'LONGEST_FIRST': + return tasksCopy.sort((a, b) => getTimeLeftForTask(b) - getTimeLeftForTask(a)); + + case 'OLDEST_FIRST': + return tasksCopy.sort((a, b) => a.created - b.created); + + case 'NEWEST_FIRST': + return tasksCopy.sort((a, b) => b.created - a.created); + + case 'BEST_FIT': + // Best-fit algorithm internally uses shortest-first for optimal gap filling + return tasksCopy.sort((a, b) => getTimeLeftForTask(a) - getTimeLeftForTask(b)); + + case 'DEFAULT': + default: + // No sorting - use existing order (sequential placement) + return tasksCopy; + } +}; diff --git a/src/app/features/schedule/schedule.service.ts b/src/app/features/schedule/schedule.service.ts index 3c42ee567..fd6753be1 100644 --- a/src/app/features/schedule/schedule.service.ts +++ b/src/app/features/schedule/schedule.service.ts @@ -13,6 +13,7 @@ import { PlannerDayMap } from '../planner/planner.model'; import { TaskWithDueTime, TaskWithSubTasks } from '../tasks/task.model'; import { TaskRepeatCfg } from '../task-repeat-cfg/task-repeat-cfg.model'; import { ScheduleConfig } from '../config/global-config.model'; +import { DEFAULT_GLOBAL_CONFIG } from '../config/default-global-config.const'; import { mapToScheduleDays } from './map-schedule-data/map-to-schedule-days'; import { Store } from '@ngrx/store'; import { selectTimelineTasks } from '../work-context/store/work-context.selectors'; @@ -84,6 +85,8 @@ export class ScheduleService { return []; } + const scheduleConfig = timelineCfg || DEFAULT_GLOBAL_CONFIG.schedule; + return mapToScheduleDays( now, daysToShow, @@ -94,6 +97,7 @@ export class ScheduleService { icalEvents ?? [], currentTaskId, plannerDayMap, + scheduleConfig, timelineCfg?.isWorkStartEndEnabled ? createWorkStartEndCfg(timelineCfg) : undefined, timelineCfg?.isLunchBreakEnabled ? createLunchBreakCfg(timelineCfg) : undefined, ); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 621a8b572..7a2b29551 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1946,6 +1946,22 @@ const T = { WORK_START_END_DESCRIPTION: 'GCF.SCHEDULE.WORK_START_END_DESCRIPTION', WEEK: 'GCF.SCHEDULE.WEEK', MONTH: 'GCF.SCHEDULE.MONTH', + L_IS_ALLOW_TASK_SPLITTING: 'GCF.SCHEDULE.L_IS_ALLOW_TASK_SPLITTING', + L_TASK_PLACEMENT_STRATEGY: 'GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY', + TASK_PLACEMENT_STRATEGY_DEFAULT: 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT', + TASK_PLACEMENT_STRATEGY_BEST_FIT: 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT', + TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST', + TASK_PLACEMENT_STRATEGY_LONGEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST', + TASK_PLACEMENT_STRATEGY_OLDEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST', + TASK_PLACEMENT_STRATEGY_NEWEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST', + TASK_PLACEMENT_STRATEGY_DEFAULT_HELP: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT_HELP', + TASK_PLACEMENT_STRATEGY_BEST_FIT_HELP: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT_HELP', }, SHORT_SYNTAX: { HELP: 'GCF.SHORT_SYNTAX.HELP', diff --git a/src/app/util/get-time-left-for-task.ts b/src/app/util/get-time-left-for-task.ts index 6d364804a..4ba7ed8b8 100644 --- a/src/app/util/get-time-left-for-task.ts +++ b/src/app/util/get-time-left-for-task.ts @@ -1,7 +1,7 @@ import { Task } from '../features/tasks/task.model'; export const getTimeLeftForTask = (task: Task): number => { - if (task.subTaskIds.length > 0) { + if (task.subTaskIds && task.subTaskIds.length > 0) { return task.timeEstimate; } return Math.max(0, task.timeEstimate - task.timeSpent) || 0; @@ -12,7 +12,7 @@ export const getTimeLeftForTasks = (tasks: Task[]): number => { }; export const getTimeLeftForTaskWithMinVal = (task: Task, minVal: number): number => { - if (task.subTaskIds.length > 0) { + if (task.subTaskIds && task.subTaskIds.length > 0) { return Math.max(minVal, task.timeEstimate); } if (typeof task.timeSpent !== 'number') { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 71ad270c5..901e5003d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1915,7 +1915,17 @@ "TITLE": "Schedule", "WORK_START_END_DESCRIPTION": "e.g. 17:00", "WEEK": "Week", - "MONTH": "Month" + "MONTH": "Month", + "L_IS_ALLOW_TASK_SPLITTING": "Allow task splitting across time boundaries", + "L_TASK_PLACEMENT_STRATEGY": "Strategy", + "TASK_PLACEMENT_STRATEGY_DEFAULT": "Default (Sequential)", + "TASK_PLACEMENT_STRATEGY_BEST_FIT": "Best Fit (reduce gaps)", + "TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST": "Shortest Task First", + "TASK_PLACEMENT_STRATEGY_LONGEST_FIRST": "Longest Task First", + "TASK_PLACEMENT_STRATEGY_OLDEST_FIRST": "Oldest Task First", + "TASK_PLACEMENT_STRATEGY_NEWEST_FIRST": "Newest Task First", + "TASK_PLACEMENT_STRATEGY_DEFAULT_HELP": "Default: Uses simple sequential placement. Tasks are placed one after another in the order they appear in your task list, starting from the work start time or current time.", + "TASK_PLACEMENT_STRATEGY_BEST_FIT_HELP": "Best Fit: Minimizes wasted time between tasks by intelligently placing tasks into gaps around scheduled items. Uses best-fit bin packing algorithm (like RAM allocation) to find the gap that results in the smallest leftover space. Only available when task splitting is disabled. Tasks are sorted shortest-first for optimal packing efficiency." }, "SHORT_SYNTAX": { "HELP": "

Here you can control short syntax options when creating a task

",