diff --git a/.gitignore b/.gitignore index bfb161534f..1548ac6488 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,5 @@ playwright-report/ electron-builder-appx.yaml +SCHEDULING_FEATURE_IMPLEMENTATION.md +SCHEDULING_ROADMAP.md diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html index 683867bd78..1c8de447da 100644 --- a/src/app/core-ui/main-header/main-header.component.html +++ b/src/app/core-ui/main-header/main-header.component.html @@ -21,6 +21,23 @@ > {{ T.GCF.SCHEDULE.MONTH | translate }} + + + {{ T.GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY | translate }} + + @for (strategy of taskPlacementStrategies; track strategy.value) { + + {{ strategy.label | translate }} + + } + + } diff --git a/src/app/core-ui/main-header/main-header.component.scss b/src/app/core-ui/main-header/main-header.component.scss index 47bc5d7df7..79a48ec10a 100644 --- a/src/app/core-ui/main-header/main-header.component.scss +++ b/src/app/core-ui/main-header/main-header.component.scss @@ -297,10 +297,11 @@ button.isActive2 { .week-month-selector { display: flex; + align-items: center; + gap: var(--s); border-radius: var(--card-border-radius); - overflow: hidden; + overflow: visible; margin-right: calc(var(--s) * 0.25); - box-shadow: var(--whiteframe-shadow-2dp); @include mq(xs) { margin-right: var(--s); @@ -308,6 +309,53 @@ button.isActive2 { @include mq(xs, max) { margin-right: calc(var(--s) * 0.125); + gap: calc(var(--s) * 0.5); + } +} + +.task-placement-select { + margin: 0; + font-size: 12px; + + ::ng-deep .mat-mdc-form-field-subscript-wrapper { + display: none; + } + + ::ng-deep .mat-mdc-text-field-wrapper { + padding: 0; + } + + ::ng-deep .mat-mdc-form-field-infix { + min-height: 36px; + padding: 6px 12px 6px 8px; + } + + ::ng-deep .mat-mdc-select { + font-size: 12px; + } + + ::ng-deep .mat-mdc-select-value { + padding-left: 4px; + } + + ::ng-deep .mat-mdc-select-arrow-wrapper { + padding-right: 4px; + } + + @include mq(xs, max) { + font-size: 11px; + ::ng-deep .mat-mdc-form-field-infix { + min-height: 32px; + padding: 4px 10px 4px 6px; + } + } +} + +// Style for dropdown panel options +::ng-deep .mat-mdc-select-panel { + .mat-mdc-option { + padding-left: 16px; + padding-right: 16px; } } diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index 08d00aabdb..93ba2a1b24 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -44,6 +44,9 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { MetricService } from '../../features/metric/metric.service'; import { DateService } from '../../core/date/date.service'; import { FocusButtonComponent } from './focus-button/focus-button.component'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { TaskPlacementStrategy } from '../../features/config/global-config.model'; @Component({ selector: 'main-header', @@ -65,6 +68,10 @@ import { FocusButtonComponent } from './focus-button/focus-button.component'; PlayButtonComponent, DesktopPanelButtonsComponent, FocusButtonComponent, + MatFormField, + MatSelect, + MatOption, + MatLabel, ], }) export class MainHeaderComponent implements OnDestroy { @@ -152,6 +159,25 @@ export class MainHeaderComponent implements OnDestroy { this._metricService.getFocusSummaryForDay(this._dateService.todayStr()), ); + scheduleConfig = toSignal( + this.globalConfigService.cfg$.pipe(map((cfg) => cfg?.schedule)), + ); + + taskPlacementStrategies: { value: TaskPlacementStrategy; label: string }[] = [ + { value: 'DEFAULT', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT }, + { + value: 'SHORTEST_FIRST', + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST, + }, + { + value: 'LONGEST_FIRST', + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST, + }, + { value: 'OLDEST_FIRST', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST }, + { value: 'NEWEST_FIRST', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST }, + { value: 'BEST_FIT', label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT }, + ]; + private _subs: Subscription = new Subscription(); selectedTimeView = computed(() => this.layoutService.selectedTimeView()); @@ -160,6 +186,12 @@ export class MainHeaderComponent implements OnDestroy { this.layoutService.selectedTimeView.set(view); } + updateTaskPlacementStrategy(strategy: TaskPlacementStrategy): void { + this.globalConfigService.updateSection('schedule', { + taskPlacementStrategy: strategy, + }); + } + ngOnDestroy(): void { this._subs.unsubscribe(); } diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index aceed8a140..5ffc13d16a 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -173,6 +173,8 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { isLunchBreakEnabled: false, lunchBreakStart: '13:00', lunchBreakEnd: '14:00', + isAllowTaskSplitting: true, + taskPlacementStrategy: 'DEFAULT', }, sync: { diff --git a/src/app/features/config/form-cfgs/schedule-form.const.ts b/src/app/features/config/form-cfgs/schedule-form.const.ts index 89a1cf4d9b..724b7c9e18 100644 --- a/src/app/features/config/form-cfgs/schedule-form.const.ts +++ b/src/app/features/config/form-cfgs/schedule-form.const.ts @@ -81,5 +81,46 @@ export const SCHEDULE_FORM_CFG: ConfigFormSection = { }, }, }, + { + key: 'isAllowTaskSplitting', + type: 'checkbox', + templateOptions: { + label: T.GCF.SCHEDULE.L_IS_ALLOW_TASK_SPLITTING, + }, + }, + { + key: 'taskPlacementStrategy', + type: 'select', + templateOptions: { + label: T.GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY, + required: true, + options: [ + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT, + value: 'DEFAULT', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST, + value: 'SHORTEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST, + value: 'LONGEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST, + value: 'OLDEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST, + value: 'NEWEST_FIRST', + }, + { + label: T.GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT, + value: 'BEST_FIT', + }, + ], + }, + }, ], }; diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 3f7dcd6cff..eabaff4910 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -141,6 +141,14 @@ export type SyncConfig = Readonly<{ localFileSync?: LocalFileSyncConfig; }>; +export type TaskPlacementStrategy = + | 'DEFAULT' + | 'BEST_FIT' + | 'SHORTEST_FIRST' + | 'LONGEST_FIRST' + | 'OLDEST_FIRST' + | 'NEWEST_FIRST'; + export type ScheduleConfig = Readonly<{ isWorkStartEndEnabled: boolean; workStart: string; @@ -148,6 +156,8 @@ export type ScheduleConfig = Readonly<{ isLunchBreakEnabled: boolean; lunchBreakStart: string; lunchBreakEnd: string; + isAllowTaskSplitting: boolean; + taskPlacementStrategy: TaskPlacementStrategy; }>; export type ReminderConfig = Readonly<{ diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 43fe778d90..0fa74be018 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1920,6 +1920,22 @@ const T = { WORK_START_END_DESCRIPTION: 'GCF.SCHEDULE.WORK_START_END_DESCRIPTION', WEEK: 'GCF.SCHEDULE.WEEK', MONTH: 'GCF.SCHEDULE.MONTH', + L_IS_ALLOW_TASK_SPLITTING: 'GCF.SCHEDULE.L_IS_ALLOW_TASK_SPLITTING', + L_TASK_PLACEMENT_STRATEGY: 'GCF.SCHEDULE.L_TASK_PLACEMENT_STRATEGY', + TASK_PLACEMENT_STRATEGY_DEFAULT: 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT', + TASK_PLACEMENT_STRATEGY_BEST_FIT: 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT', + TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_SHORTEST_FIRST', + TASK_PLACEMENT_STRATEGY_LONGEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_LONGEST_FIRST', + TASK_PLACEMENT_STRATEGY_OLDEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_OLDEST_FIRST', + TASK_PLACEMENT_STRATEGY_NEWEST_FIRST: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_NEWEST_FIRST', + TASK_PLACEMENT_STRATEGY_DEFAULT_HELP: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_DEFAULT_HELP', + TASK_PLACEMENT_STRATEGY_BEST_FIT_HELP: + 'GCF.SCHEDULE.TASK_PLACEMENT_STRATEGY_BEST_FIT_HELP', }, SHORT_SYNTAX: { HELP: 'GCF.SHORT_SYNTAX.HELP', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 8b32c5b77b..1d3a164020 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1889,7 +1889,17 @@ "TITLE": "Schedule", "WORK_START_END_DESCRIPTION": "e.g. 17:00", "WEEK": "Week", - "MONTH": "Month" + "MONTH": "Month", + "L_IS_ALLOW_TASK_SPLITTING": "Allow task splitting across time boundaries", + "L_TASK_PLACEMENT_STRATEGY": "Strategy", + "TASK_PLACEMENT_STRATEGY_DEFAULT": "Default (Sequential)", + "TASK_PLACEMENT_STRATEGY_BEST_FIT": "Best Fit (Experimental)", + "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: Uses best-fit bin packing algorithm to minimize task splitting by placing tasks into gaps between scheduled items. Tasks are sorted by remaining time (shortest first) for optimal gap utilization." }, "SHORT_SYNTAX": { "HELP": "

Here you can control short syntax options when creating a task

",