): 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
",