mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
Revert "Merge pull request #5420 from overcuriousity/scheduling"
This reverts commitfcd4f723aa, reversing changes made toaafd039dab.
This commit is contained in:
parent
78d2d7c6a0
commit
844cc006ed
20 changed files with 136 additions and 1170 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -111,4 +111,4 @@ e2e-webdav-data
|
|||
playwright-report/
|
||||
|
||||
|
||||
electron-builder-appx.yaml
|
||||
electron-builder-appx.yaml
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,8 +173,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isLunchBreakEnabled: false,
|
||||
lunchBreakStart: '13:00',
|
||||
lunchBreakEnd: '14:00',
|
||||
isAllowTaskSplitting: true,
|
||||
taskPlacementStrategy: 'DEFAULT',
|
||||
},
|
||||
|
||||
sync: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue