Revert "Merge pull request #5420 from overcuriousity/scheduling"

This reverts commit fcd4f723aa, reversing
changes made to aafd039dab.
This commit is contained in:
Johannes Millan 2025-11-17 14:33:23 +01:00
parent 78d2d7c6a0
commit 844cc006ed
20 changed files with 136 additions and 1170 deletions

2
.gitignore vendored
View file

@ -111,4 +111,4 @@ e2e-webdav-data
playwright-report/
electron-builder-appx.yaml
electron-builder-appx.yaml

View file

@ -297,11 +297,10 @@ button.isActive2 {
.week-month-selector {
display: flex;
align-items: center;
gap: var(--s);
border-radius: var(--card-border-radius);
overflow: visible;
overflow: hidden;
margin-right: calc(var(--s) * 0.25);
box-shadow: var(--whiteframe-shadow-2dp);
@include mq(xs) {
margin-right: var(--s);
@ -309,43 +308,20 @@ button.isActive2 {
@include mq(xs, max) {
margin-right: calc(var(--s) * 0.125);
gap: calc(var(--s) * 0.5);
}
.task-placement-btn {
padding: 4px;
}
}
// Menu panel lives in an overlay; use ::ng-deep for styling menu items
:host ::ng-deep .task-placement-menu-item {
display: flex;
align-items: center;
.selected-icon {
margin-right: var(--s-half);
opacity: 0;
}
&.active {
color: var(--c-accent);
.selected-icon {
opacity: 1;
}
}
}
.week-month-btn {
min-width: 48px;
height: 36px;
padding: 0;
padding: 0 var(--s);
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 0;
transition: var(--transition-standard);
cursor: pointer;
position: relative;
margin-left: -8px;
@include mq(xs, max) {
min-width: 40px;
@ -380,17 +356,11 @@ button.isActive2 {
border-right: 1px solid rgba(255, 255, 255, 0.2);
border-top-left-radius: var(--card-border-radius);
border-bottom-left-radius: var(--card-border-radius);
margin-left: 0;
@include darkTheme() {
border-right-color: rgba(255, 255, 255, 0.1);
}
}
&:last-of-type {
border-top-right-radius: var(--card-border-radius);
border-bottom-right-radius: var(--card-border-radius);
}
}
.current-task-title {

View file

@ -44,9 +44,6 @@ 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 { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { TaskPlacementStrategy } from '../../features/config/global-config.model';
import { NgIf } from '@angular/common';
@Component({
selector: 'main-header',
@ -68,10 +65,6 @@ import { NgIf } from '@angular/common';
PlayButtonComponent,
DesktopPanelButtonsComponent,
FocusButtonComponent,
MatMenuTrigger,
MatMenu,
MatMenuItem,
NgIf,
],
})
export class MainHeaderComponent implements OnDestroy {
@ -159,28 +152,6 @@ export class MainHeaderComponent implements OnDestroy {
this._metricService.getFocusSummaryForDay(this._dateService.todayStr()),
);
scheduleConfig = toSignal(
this.globalConfigService.cfg$.pipe(map((cfg) => cfg?.schedule)),
);
selectedTaskPlacementStrategy = computed(
() => this.scheduleConfig()?.taskPlacementStrategy || 'DEFAULT',
);
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());
@ -189,12 +160,6 @@ 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();
}

View file

@ -173,8 +173,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isLunchBreakEnabled: false,
lunchBreakStart: '13:00',
lunchBreakEnd: '14:00',
isAllowTaskSplitting: true,
taskPlacementStrategy: 'DEFAULT',
},
sync: {

View file

@ -81,48 +81,5 @@ 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,
},
],
},
},
],
};

View file

@ -141,14 +141,6 @@ 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;
@ -156,8 +148,6 @@ export type ScheduleConfig = Readonly<{
isLunchBreakEnabled: boolean;
lunchBreakStart: string;
lunchBreakEnd: string;
isAllowTaskSplitting: boolean;
taskPlacementStrategy: TaskPlacementStrategy;
}>;
export type ReminderConfig = Readonly<{

View file

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { filter, tap, withLatestFrom, map } from 'rxjs/operators';
import { filter, tap, withLatestFrom } 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, ScheduleConfig } from '../global-config.model';
import { MiscConfig } from '../global-config.model';
@Injectable()
export class GlobalConfigEffects {
@ -159,42 +159,4 @@ 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: T.GCF.SCHEDULE.BEST_FIT_REQUIRES_NO_SPLIT,
ico: 'warning',
});
return updateGlobalConfigSection({
sectionKey: 'schedule',
sectionCfg: { taskPlacementStrategy: 'DEFAULT' },
isSkipSnack: true,
});
}),
),
);
}

View file

@ -12,7 +12,6 @@ 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';
@ -30,7 +29,6 @@ export const createScheduleDays = (
blockerBlocksDayMap: BlockedBlockByDayMap,
workStartEndCfg: ScheduleWorkStartEndCfg | undefined,
now: number,
scheduleConfig: ScheduleConfig,
): ScheduleDay[] => {
let viewEntriesPushedToNextDay: SVEEntryForNextDay[];
let flowTasksLeftAfterDay: TaskWithoutReminder[] = nonScheduledTasks.map((task) => {
@ -102,16 +100,6 @@ 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,
@ -119,51 +107,11 @@ 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) => {

View file

@ -10,12 +10,8 @@ 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 = (
@ -25,8 +21,6 @@ export const createViewEntriesForDay = (
nonScheduledTasksForDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
blockedBlocksForDay: BlockedBlock[],
viewEntriesPushedToNextDay: SVEEntryForNextDay[],
scheduleConfig: ScheduleConfig,
dayEndTime: number,
): SVE[] => {
let viewEntries: SVE[] = [];
let startTime = initialStartTime;
@ -52,42 +46,9 @@ export const createViewEntriesForDay = (
}
if (nonScheduledTasksForDay.length) {
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),
);
}
}
viewEntries = viewEntries.concat(
createScheduleViewEntriesForNormalTasks(startTime, nonScheduledTasksForDay),
);
}
insertBlockedBlocksViewEntriesForSchedule(

View file

@ -128,43 +128,119 @@ 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].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);
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);
});
});

View file

@ -2,7 +2,6 @@ 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();
@ -88,36 +87,10 @@ 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,
{},
fakeScheduleConfig(),
undefined,
),
mapToScheduleDays(N, [], [], [], [], [], [], null, {}, undefined, undefined),
).toEqual([]);
});
@ -136,7 +109,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
),
).toEqual([
@ -189,7 +162,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
);
expect(r[0].entries.length).toBe(3);
@ -257,15 +230,8 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig({
isWorkStartEndEnabled: true,
workStart: '9:00',
workEnd: '18:00',
}),
{
startTime: '9:00',
endTime: '18:00',
},
{ startTime: '9:00', endTime: '18:00' },
undefined,
);
expect(r.length).toBe(2);
@ -295,7 +261,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
);
expect(r[0].entries.length).toBe(5);
@ -366,7 +332,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
);
@ -424,7 +390,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
);
@ -503,7 +469,7 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig(),
undefined,
undefined,
);
@ -569,15 +535,11 @@ describe('mapToScheduleDays()', () => {
[],
null,
{},
fakeScheduleConfig({
isWorkStartEndEnabled: true,
workStart: '9:00',
workEnd: '17:00',
}),
{
startTime: '9:00',
endTime: '17:00',
},
undefined,
);
expect(r[0]).toEqual({
@ -670,7 +632,7 @@ describe('mapToScheduleDays()', () => {
fakeTaskEntry('FD4', { timeEstimate: h(0.5) }),
],
},
fakeScheduleConfig(),
undefined,
undefined,
);
@ -758,14 +720,6 @@ 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',
@ -907,14 +861,6 @@ 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',

View file

@ -8,7 +8,6 @@ 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';
@ -23,7 +22,6 @@ export const mapToScheduleDays = (
calenderWithItems: ScheduleCalendarMapEntry[],
currentId: string | null,
plannerDayMap: PlannerDayMap,
scheduleConfig: ScheduleConfig,
workStartEndCfg: ScheduleWorkStartEndCfg = {
startTime: '0:00',
endTime: '23:59',
@ -92,7 +90,6 @@ export const mapToScheduleDays = (
blockerBlocksDayMap,
workStartEndCfg,
now,
scheduleConfig,
);
return v;

View file

@ -1,235 +0,0 @@
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
});
});

View file

@ -1,263 +0,0 @@
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;
};

View file

@ -1,191 +0,0 @@
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;
};

View file

@ -1,42 +0,0 @@
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;
}
};

View file

@ -13,7 +13,6 @@ 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';
@ -85,8 +84,6 @@ export class ScheduleService {
return [];
}
const scheduleConfig = timelineCfg || DEFAULT_GLOBAL_CONFIG.schedule;
return mapToScheduleDays(
now,
daysToShow,
@ -97,7 +94,6 @@ export class ScheduleService {
icalEvents ?? [],
currentTaskId,
plannerDayMap,
scheduleConfig,
timelineCfg?.isWorkStartEndEnabled ? createWorkStartEndCfg(timelineCfg) : undefined,
timelineCfg?.isLunchBreakEnabled ? createLunchBreakCfg(timelineCfg) : undefined,
);

View file

@ -658,23 +658,6 @@ const T = {
SCORE_BREAKDOWN_TITLE_SUSTAINABILITY:
'F.METRIC.EVAL_FORM.SCORE_BREAKDOWN_TITLE_SUSTAINABILITY',
},
REFLECTION: {
TITLE: 'F.METRIC.REFLECTION.TITLE',
SUBLINE: 'F.METRIC.REFLECTION.SUBLINE',
PLACEHOLDER_1: 'F.METRIC.REFLECTION.PLACEHOLDER_1',
PLACEHOLDER_2: 'F.METRIC.REFLECTION.PLACEHOLDER_2',
PLACEHOLDER_3: 'F.METRIC.REFLECTION.PLACEHOLDER_3',
PLACEHOLDER_4: 'F.METRIC.REFLECTION.PLACEHOLDER_4',
PLACEHOLDER_5: 'F.METRIC.REFLECTION.PLACEHOLDER_5',
REMIND_LABEL: 'F.METRIC.REFLECTION.REMIND_LABEL',
HISTORY_BTN: 'F.METRIC.REFLECTION.HISTORY_BTN',
HISTORY_TITLE: 'F.METRIC.REFLECTION.HISTORY_TITLE',
HISTORY_EMPTY: 'F.METRIC.REFLECTION.HISTORY_EMPTY',
REMINDER_TASK_TITLE: 'F.METRIC.REFLECTION.REMINDER_TASK_TITLE',
REMINDER_CREATED: 'F.METRIC.REFLECTION.REMINDER_CREATED',
REMINDER_ERROR: 'F.METRIC.REFLECTION.REMINDER_ERROR',
REMINDER_NEEDS_TEXT: 'F.METRIC.REFLECTION.REMINDER_NEEDS_TEXT',
},
S: {
SAVE_METRIC: 'F.METRIC.S.SAVE_METRIC',
},
@ -699,10 +682,6 @@ const T = {
VIEW_SPLIT: 'F.NOTE.D_FULLSCREEN.VIEW_SPLIT',
VIEW_TEXT_ONLY: 'F.NOTE.D_FULLSCREEN.VIEW_TEXT_ONLY',
},
B_STARTUP: {
MSG: 'F.NOTE.B_STARTUP.MSG',
ACTION_DISMISS: 'F.NOTE.B_STARTUP.ACTION_DISMISS',
},
NOTE_CMP: {
DISABLE_PARSE: 'F.NOTE.NOTE_CMP.DISABLE_PARSE',
ENABLE_PARSE: 'F.NOTE.NOTE_CMP.ENABLE_PARSE',
@ -1967,23 +1946,6 @@ 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',
BEST_FIT_REQUIRES_NO_SPLIT: 'GCF.SCHEDULE.BEST_FIT_REQUIRES_NO_SPLIT',
},
SHORT_SYNTAX: {
HELP: 'GCF.SHORT_SYNTAX.HELP',

View file

@ -1,7 +1,7 @@
import { Task } from '../features/tasks/task.model';
export const getTimeLeftForTask = (task: Task): number => {
if (task.subTaskIds && task.subTaskIds.length > 0) {
if (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 && task.subTaskIds.length > 0) {
if (task.subTaskIds.length > 0) {
return Math.max(minVal, task.timeEstimate);
}
if (typeof task.timeSpent !== 'number') {

View file

@ -660,22 +660,6 @@
"ADD_SESSION": "Add Session",
"NEW_SESSION_DURATION": "Duration",
"ADD_BTN": "Add"
},
"REFLECTION": {
"TITLE": "Reflection Note",
"PLACEHOLDER_1": "What worked well today? What should change tomorrow?",
"PLACEHOLDER_2": "Small improvement for tomorrow?",
"PLACEHOLDER_3": "Where did focus slip - and how can you protect it?",
"PLACEHOLDER_4": "Which task gave you energy? Which drained it?",
"PLACEHOLDER_5": "One thing to repeat. One thing to change.",
"REMIND_LABEL": "Remind me tomorrow about this",
"HISTORY_BTN": "View past reflections",
"HISTORY_TITLE": "Reflection history",
"HISTORY_EMPTY": "No reflections saved yet. Capture today's note to start a streak.",
"REMINDER_TASK_TITLE": "Revisit reflection note",
"REMINDER_CREATED": "Reflection reminder scheduled for tomorrow",
"REMINDER_ERROR": "Could not schedule the reminder",
"REMINDER_NEEDS_TEXT": "Write a reflection before asking for a reminder"
}
},
"NOTE": {
@ -687,10 +671,6 @@
"VIEW_SPLIT": "View parsed and unparsed markdown in split view",
"VIEW_TEXT_ONLY": "View as unparsed text"
},
"B_STARTUP": {
"MSG": "Reflection Note: ({{date}}): {{content}}",
"ACTION_DISMISS": "Dismiss"
},
"NOTE_CMP": {
"DISABLE_PARSE": "Disable markdown parsing for preview",
"ENABLE_PARSE": "Enable markdown parse"
@ -1935,18 +1915,7 @@
"TITLE": "Schedule",
"WORK_START_END_DESCRIPTION": "e.g. 17:00",
"WEEK": "Week",
"MONTH": "Month",
"L_IS_ALLOW_TASK_SPLITTING": "Allow task splitting across time boundaries",
"L_TASK_PLACEMENT_STRATEGY": "Sort tasks without a scheduled time (overwrites manual sorting)",
"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.",
"BEST_FIT_REQUIRES_NO_SPLIT": "Best Fit requires task splitting to be disabled. Switching back to Default."
"MONTH": "Month"
},
"SHORT_SYNTAX": {
"HELP": "<p>Here you can control short syntax options when creating a task</p>",