mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
Merge pull request #5420 from overcuriousity/scheduling
feat: improve automatic scheduling for tasks with no planned time
This commit is contained in:
commit
fcd4f723aa
21 changed files with 1156 additions and 133 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -111,4 +111,4 @@ e2e-webdav-data
|
|||
playwright-report/
|
||||
|
||||
|
||||
electron-builder-appx.yaml
|
||||
electron-builder-appx.yaml
|
||||
|
|
@ -21,6 +21,23 @@
|
|||
>
|
||||
{{ T.GCF.SCHEDULE.MONTH | translate }}
|
||||
</button>
|
||||
|
||||
<mat-form-field
|
||||
class="task-placement-select"
|
||||
appearance="outline"
|
||||
>
|
||||
<mat-label>{{ T.GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY | translate }}</mat-label>
|
||||
<mat-select
|
||||
[value]="scheduleConfig()?.taskPlacementStrategy || 'DEFAULT'"
|
||||
(selectionChange)="updateTaskPlacementStrategy($event.value)"
|
||||
>
|
||||
@for (strategy of taskPlacementStrategies; track strategy.value) {
|
||||
<mat-option [value]="strategy.value">
|
||||
{{ strategy.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isLunchBreakEnabled: false,
|
||||
lunchBreakStart: '13:00',
|
||||
lunchBreakEnd: '14:00',
|
||||
isAllowTaskSplitting: true,
|
||||
taskPlacementStrategy: 'DEFAULT',
|
||||
},
|
||||
|
||||
sync: {
|
||||
|
|
|
|||
|
|
@ -81,5 +81,48 @@ export const SCHEDULE_FORM_CFG: ConfigFormSection<ScheduleConfig> = {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TaskRepeatCfg> as TaskRepeatCfg;
|
||||
};
|
||||
|
||||
const fakeScheduleConfig = (add?: Partial<ScheduleConfig>): 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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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": "<p>Here you can control short syntax options when creating a task</p>",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue