mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-03 13:02:26 +00:00
Merge branch 'feat/calendar-panel-cleaner'
* feat/calendar-panel-cleaner: (50 commits) feat(scheduleDayPanel): add message for first time users fix(scheduleDayPanel): animation feat: outline help-box component fix(scheduleRightPanel): styling of create-task-placeholder.component.scss feat(scheduleRightPanel): improve drop before feat(scheduleRightPanel): improve styling feat(scheduleRightPanel): improve drag over 1 feat(scheduleRightPanel): refresh panel every two minutes feat(scheduleRightPanel): make scaling work feat(scheduleRightPanel): fix jumping header button feat(scheduleRightPanel): consider current task in panel too feat(scheduleRightPanel): don't play task snapback ani feat(scheduleRightPanel): improve on drag out to unschedule 2 feat(scheduleRightPanel): improve on drag out to unschedule feat(scheduleRightPanel): use schedule-event as preview when dropping tasks refactor(scheduleRightPanel): share scheduled days feat(scheduleRightPanel): improve styling refactor(scheduleRightPanel): improve code quality feat(scheduleRightPanel): make drag task on schedule work better feat(scheduleRightPanel): make it work for touch ...
This commit is contained in:
commit
a1f0ed1e94
41 changed files with 2663 additions and 707 deletions
|
|
@ -8,6 +8,8 @@ import {
|
|||
toggleIssuePanel,
|
||||
toggleShowNotes,
|
||||
toggleTaskViewCustomizerPanel,
|
||||
toggleScheduleDayPanel,
|
||||
hideScheduleDayPanel,
|
||||
} from './store/layout.actions';
|
||||
import { Observable } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
|
|
@ -17,6 +19,7 @@ import {
|
|||
selectIsShowIssuePanel,
|
||||
selectIsShowNotes,
|
||||
selectIsShowTaskViewCustomizerPanel,
|
||||
selectIsShowScheduleDayPanel,
|
||||
} from './store/layout.reducer';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
|
|
@ -85,6 +88,11 @@ export class LayoutService {
|
|||
|
||||
readonly isShowIssuePanel = toSignal(this.isShowIssuePanel$, { initialValue: false });
|
||||
|
||||
readonly isShowScheduleDayPanel = toSignal(
|
||||
this._store$.pipe(select(selectIsShowScheduleDayPanel)),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
showAddTaskBar(): void {
|
||||
// Store currently focused element if it's a task
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
|
@ -138,4 +146,13 @@ export class LayoutService {
|
|||
// Trigger the focus signal - components listening to this signal will handle the focus
|
||||
this._focusSideNavTrigger.update((value) => value + 1);
|
||||
}
|
||||
|
||||
// Schedule Day Panel controls
|
||||
toggleScheduleDayPanel(): void {
|
||||
this._store$.dispatch(toggleScheduleDayPanel());
|
||||
}
|
||||
|
||||
hideScheduleDayPanel(): void {
|
||||
this._store$.dispatch(hideScheduleDayPanel());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,3 +37,8 @@ export const togglePluginPanel = createAction(
|
|||
'[Layout] Toggle PluginPanel',
|
||||
(pluginId: string) => ({ pluginId }),
|
||||
);
|
||||
|
||||
// Schedule Day Panel
|
||||
export const toggleScheduleDayPanel = createAction('[Layout] Toggle ScheduleDayPanel');
|
||||
|
||||
export const hideScheduleDayPanel = createAction('[Layout] Hide ScheduleDayPanel');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
|
|||
import { hideNonTaskSidePanelContent } from './layout.actions';
|
||||
import { filter, mapTo } from 'rxjs/operators';
|
||||
import { setSelectedTask } from '../../../features/tasks/store/task.actions';
|
||||
import { TaskDetailTargetPanel } from '../../../features/tasks/task.model';
|
||||
import { LayoutService } from '../layout.service';
|
||||
import { NavigationStart, NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
|
|
@ -23,7 +24,12 @@ export class LayoutEffects {
|
|||
hideNotesWhenTaskIsSelected$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(setSelectedTask),
|
||||
filter(({ id }) => id !== null),
|
||||
filter(({ id, taskDetailTargetPanel, isSkipToggle }) => {
|
||||
// Do not hide side content when opening modal (DONT_OPEN_PANEL) or when explicitly skipped
|
||||
if (id === null) return false;
|
||||
if (isSkipToggle) return false;
|
||||
return taskDetailTargetPanel !== TaskDetailTargetPanel.DONT_OPEN_PANEL;
|
||||
}),
|
||||
mapTo(hideNonTaskSidePanelContent()),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
toggleShowNotes,
|
||||
toggleTaskViewCustomizerPanel,
|
||||
} from './layout.actions';
|
||||
import { toggleScheduleDayPanel, hideScheduleDayPanel } from './layout.actions';
|
||||
import {
|
||||
Action,
|
||||
createFeatureSelector,
|
||||
|
|
@ -27,6 +28,7 @@ export interface LayoutState {
|
|||
isShowTaskViewCustomizerPanel: boolean;
|
||||
isShowPluginPanel: boolean;
|
||||
activePluginId: string | null;
|
||||
isShowScheduleDayPanel: boolean;
|
||||
}
|
||||
|
||||
export const INITIAL_LAYOUT_STATE: LayoutState = {
|
||||
|
|
@ -37,6 +39,7 @@ export const INITIAL_LAYOUT_STATE: LayoutState = {
|
|||
isShowTaskViewCustomizerPanel: false,
|
||||
isShowPluginPanel: false,
|
||||
activePluginId: null,
|
||||
isShowScheduleDayPanel: false,
|
||||
};
|
||||
|
||||
export const selectLayoutFeatureState =
|
||||
|
|
@ -77,12 +80,18 @@ export const selectActivePluginId = createSelector(
|
|||
(state) => state.activePluginId,
|
||||
);
|
||||
|
||||
export const selectIsShowScheduleDayPanel = createSelector(
|
||||
selectLayoutFeatureState,
|
||||
(state) => state.isShowScheduleDayPanel,
|
||||
);
|
||||
|
||||
const ALL_PANEL_CONTENT_HIDDEN: Partial<LayoutState> = {
|
||||
isShowNotes: false,
|
||||
isShowIssuePanel: false,
|
||||
isShowTaskViewCustomizerPanel: false,
|
||||
isShowPluginPanel: false,
|
||||
activePluginId: null,
|
||||
isShowScheduleDayPanel: false,
|
||||
};
|
||||
|
||||
const _reducer = createReducer<LayoutState>(
|
||||
|
|
@ -138,6 +147,16 @@ const _reducer = createReducer<LayoutState>(
|
|||
activePluginId: isCurrentlyActive ? null : pluginId,
|
||||
};
|
||||
}),
|
||||
on(toggleScheduleDayPanel, (state) => ({
|
||||
...state,
|
||||
...ALL_PANEL_CONTENT_HIDDEN,
|
||||
isShowScheduleDayPanel: !state.isShowScheduleDayPanel,
|
||||
})),
|
||||
on(hideScheduleDayPanel, (state) => ({
|
||||
...state,
|
||||
...ALL_PANEL_CONTENT_HIDDEN,
|
||||
isShowScheduleDayPanel: false,
|
||||
})),
|
||||
);
|
||||
|
||||
export const layoutReducer = (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ import { KeyboardConfig } from '../../../features/config/keyboard-config.model';
|
|||
standalone: true,
|
||||
imports: [MatIconButton, MatIcon, MatTooltip, TranslatePipe],
|
||||
template: `
|
||||
<button
|
||||
class="panel-btn"
|
||||
[disabled]="!isRouteWithSidePanel()"
|
||||
[class.isActive]="isShowScheduleDayPanel()"
|
||||
(click)="layoutService.toggleScheduleDayPanel()"
|
||||
mat-icon-button
|
||||
matTooltip="{{ T.MH.SCHEDULE | translate }}"
|
||||
>
|
||||
<mat-icon svgIcon="early_on"></mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="panel-btn"
|
||||
[disabled]="!isWorkViewPage()"
|
||||
|
|
@ -118,6 +129,7 @@ export class DesktopPanelButtonsComponent {
|
|||
readonly kb = input<KeyboardConfig | null>();
|
||||
readonly isRouteWithSidePanel = input.required<boolean>();
|
||||
readonly isWorkViewPage = input.required<boolean>();
|
||||
readonly isShowScheduleDayPanel = input.required<boolean>();
|
||||
readonly isShowTaskViewCustomizerPanel = input.required<boolean>();
|
||||
readonly isShowIssuePanel = input.required<boolean>();
|
||||
readonly isShowNotes = input.required<boolean>();
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
[kb]="kb"
|
||||
[isRouteWithSidePanel]="isRouteWithSidePanel()"
|
||||
[isWorkViewPage]="isWorkViewPage()"
|
||||
[isShowScheduleDayPanel]="isShowScheduleDayPanel()"
|
||||
[isShowTaskViewCustomizerPanel]="isShowTaskViewCustomizerPanel()"
|
||||
[isShowIssuePanel]="isShowIssuePanel()"
|
||||
[isShowNotes]="isShowNotes()"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export class MainHeaderComponent implements OnDestroy {
|
|||
);
|
||||
isShowIssuePanel = computed(() => this.layoutService.isShowIssuePanel());
|
||||
isShowNotes = computed(() => this.layoutService.isShowNotes());
|
||||
isShowScheduleDayPanel = computed(() => this.layoutService.isShowScheduleDayPanel());
|
||||
syncIsEnabledAndReady = toSignal(this.syncWrapperService.isEnabledAndReady$);
|
||||
syncState = toSignal(this.syncWrapperService.syncState$);
|
||||
isSyncInProgress = toSignal(this.syncWrapperService.isSyncInProgress$);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export type PanelContentType =
|
|||
| 'ADD_TASK_PANEL'
|
||||
| 'ISSUE_PANEL'
|
||||
| 'TASK_VIEW_CUSTOMIZER_PANEL'
|
||||
| 'PLUGIN';
|
||||
| 'PLUGIN'
|
||||
| 'SCHEDULE_DAY_PANEL';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PanelContentService {
|
||||
|
|
@ -47,12 +48,14 @@ export class PanelContentService {
|
|||
isShowIssuePanel,
|
||||
isShowTaskViewCustomizerPanel,
|
||||
isShowPluginPanel,
|
||||
isShowScheduleDayPanel,
|
||||
} = layoutState;
|
||||
|
||||
if (isShowNotes) return 'NOTES';
|
||||
if (isShowIssuePanel) return 'ISSUE_PANEL';
|
||||
if (isShowTaskViewCustomizerPanel) return 'TASK_VIEW_CUSTOMIZER_PANEL';
|
||||
if (isShowPluginPanel) return 'PLUGIN';
|
||||
if (isShowScheduleDayPanel) return 'SCHEDULE_DAY_PANEL';
|
||||
if (selectedTask) return 'TASK';
|
||||
return null;
|
||||
});
|
||||
|
|
@ -66,13 +69,15 @@ export class PanelContentService {
|
|||
isShowIssuePanel,
|
||||
isShowTaskViewCustomizerPanel,
|
||||
isShowPluginPanel,
|
||||
isShowScheduleDayPanel,
|
||||
} = layoutState;
|
||||
return !!(
|
||||
selectedTask ||
|
||||
isShowNotes ||
|
||||
isShowIssuePanel ||
|
||||
isShowTaskViewCustomizerPanel ||
|
||||
isShowPluginPanel
|
||||
isShowPluginPanel ||
|
||||
isShowScheduleDayPanel
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { plannerInitialState, plannerReducer } from './planner.reducer';
|
||||
import { PlannerActions } from './planner.actions';
|
||||
import { DEFAULT_TASK } from '../../tasks/task.model';
|
||||
import * as getDbDateStrUtil from '../../../util/get-db-date-str';
|
||||
|
||||
describe('Planner Reducer', () => {
|
||||
describe('an unknown action', () => {
|
||||
|
|
@ -56,4 +58,125 @@ describe('Planner Reducer', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTaskForDay', () => {
|
||||
it('should not add task to planner days when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days[todayStr]).toBeUndefined();
|
||||
expect(result.days['2024-01-16']).toEqual(['task2']);
|
||||
});
|
||||
|
||||
it('should remove task from all days when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'task2'],
|
||||
'2024-01-17': ['task3'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days[todayStr]).toBeUndefined();
|
||||
expect(result.days['2024-01-16']).toEqual(['task2']);
|
||||
expect(result.days['2024-01-17']).toEqual(['task3']);
|
||||
});
|
||||
|
||||
it('should add task to planner days when planning for future day', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task2', 'task1']);
|
||||
});
|
||||
|
||||
it('should add task to top when isAddToTop is true', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16',
|
||||
isAddToTop: true,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task1', 'task2']);
|
||||
});
|
||||
|
||||
it('should handle reordering task within same day', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16',
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task2', 'task3', 'task1']);
|
||||
});
|
||||
|
||||
it('should remove subtasks when moving parent task', () => {
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: {
|
||||
...DEFAULT_TASK,
|
||||
id: 'parent',
|
||||
projectId: 'test',
|
||||
subTaskIds: ['sub1', 'sub2'],
|
||||
},
|
||||
day: '2024-01-16',
|
||||
isAddToTop: false,
|
||||
});
|
||||
const result = plannerReducer(
|
||||
{
|
||||
...plannerInitialState,
|
||||
days: {
|
||||
'2024-01-16': ['task1', 'sub1', 'task2', 'sub2'],
|
||||
},
|
||||
},
|
||||
action,
|
||||
);
|
||||
expect(result.days['2024-01-16']).toEqual(['task1', 'task2', 'parent']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -154,15 +154,19 @@ export const plannerReducer = createReducer(
|
|||
|
||||
on(PlannerActions.planTaskForDay, (state, { task, day, isAddToTop }) => {
|
||||
const daysCopy = { ...state.days };
|
||||
// filter out from other days
|
||||
// filter out from other days (including the target day to handle reordering)
|
||||
Object.keys(daysCopy).forEach((dayI) => {
|
||||
daysCopy[dayI] = daysCopy[dayI].filter((id) => id !== task.id);
|
||||
});
|
||||
const isPlannedForToday = day === getDbDateStr();
|
||||
|
||||
const todayStr = getDbDateStr();
|
||||
const isPlannedForToday = day === todayStr;
|
||||
|
||||
return {
|
||||
...state,
|
||||
days: {
|
||||
...daysCopy,
|
||||
// Only add to planner days if NOT today (today is managed by today tag)
|
||||
...(isPlannedForToday
|
||||
? {}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
<plugin-panel-container [@slideInFromRight]></plugin-panel-container>
|
||||
}
|
||||
<!-- Task content comes last so we can avoid an extra effect to unset selected task -->
|
||||
} @else if (panelContent() === 'SCHEDULE_DAY_PANEL') {
|
||||
<schedule-day-panel [@slideInFromRight]></schedule-day-panel>
|
||||
} @else {
|
||||
@if (selectedTaskWithDelayForNone(); as task) {
|
||||
<task-detail-panel
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { TaskDetailPanelComponent } from '../tasks/task-detail-panel/task-detail
|
|||
import { TaskViewCustomizerPanelComponent } from '../task-view-customizer/task-view-customizer-panel/task-view-customizer-panel.component';
|
||||
import { PluginService } from '../../plugins/plugin.service';
|
||||
import { PluginPanelContainerComponent } from '../../plugins/ui/plugin-panel-container/plugin-panel-container.component';
|
||||
import { ScheduleDayPanelComponent } from '../schedule/schedule-day-panel/schedule-day-panel.component';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
INITIAL_LAYOUT_STATE,
|
||||
|
|
@ -57,6 +58,7 @@ export type RightPanelContentPanelType = PanelContentType;
|
|||
TaskDetailPanelComponent,
|
||||
TaskViewCustomizerPanelComponent,
|
||||
PluginPanelContainerComponent,
|
||||
ScheduleDayPanelComponent,
|
||||
],
|
||||
})
|
||||
export class RightPanelContentComponent implements OnDestroy {
|
||||
|
|
@ -169,6 +171,7 @@ export class RightPanelContentComponent implements OnDestroy {
|
|||
isShowIssuePanel: isShowAddTaskPanel,
|
||||
isShowTaskViewCustomizerPanel,
|
||||
isShowPluginPanel,
|
||||
isShowScheduleDayPanel,
|
||||
} = layoutState;
|
||||
|
||||
const isWorkView = this._isWorkViewUrl(currentRoute);
|
||||
|
|
@ -186,7 +189,8 @@ export class RightPanelContentComponent implements OnDestroy {
|
|||
isShowNotes ||
|
||||
isShowAddTaskPanel ||
|
||||
isShowTaskViewCustomizerPanel ||
|
||||
isShowPluginPanel
|
||||
isShowPluginPanel ||
|
||||
isShowScheduleDayPanel
|
||||
) && targetPanel !== TaskDetailTargetPanel.DONT_OPEN_PANEL
|
||||
);
|
||||
});
|
||||
|
|
@ -288,6 +292,7 @@ export class RightPanelContentComponent implements OnDestroy {
|
|||
this.layoutService.hideNotes();
|
||||
this.layoutService.hideAddTaskPanel();
|
||||
this.layoutService.hideTaskViewCustomizerPanel();
|
||||
this.layoutService.hideScheduleDayPanel();
|
||||
this.store.dispatch(hidePluginPanel());
|
||||
this.onClose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ export class RightPanelComponent implements AfterViewInit, OnDestroy {
|
|||
this._layoutService.hideNotes();
|
||||
this._layoutService.hideAddTaskPanel();
|
||||
this._layoutService.hideTaskViewCustomizerPanel();
|
||||
this._layoutService.hideScheduleDayPanel();
|
||||
this._store.dispatch(hidePluginPanel());
|
||||
|
||||
this.wasClosed.emit();
|
||||
|
|
|
|||
|
|
@ -71,10 +71,9 @@
|
|||
.task-input-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 8px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
@include mq(xs, max) {
|
||||
|
|
@ -91,6 +90,7 @@
|
|||
select-task-minimal {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
//flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.task-type-indicator {
|
||||
|
|
@ -148,9 +148,7 @@
|
|||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
margin-top: auto;
|
||||
margin-bottom: -5px;
|
||||
border-top: 1px dashed var(--extra-border-color);
|
||||
padding-top: 3px;
|
||||
|
||||
@include mq(xs, max) {
|
||||
font-size: 11px;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<div
|
||||
#dropZone
|
||||
class="schedule-day-drop-zone"
|
||||
(pointerenter)="onDragMove($event)"
|
||||
(mousemove)="onDragMove($event)"
|
||||
(touchmove)="onDragMove($event)"
|
||||
>
|
||||
@if (hasNoEvents()) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-text">Drop tasks here to schedule them</div>
|
||||
</div>
|
||||
}
|
||||
<schedule-week
|
||||
#scheduleWeek
|
||||
[isInPanel]="true"
|
||||
[events]="events()"
|
||||
[beyondBudget]="[]"
|
||||
[daysToShow]="daysToShow()"
|
||||
[workStartEnd]="workStartEnd() || null"
|
||||
[currentTimeRow]="currentTimeRow()"
|
||||
[isTaskDragActive]="isDragging()"
|
||||
></schedule-week>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
:host {
|
||||
display: block;
|
||||
|
||||
> * {
|
||||
inset: 4px;
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-day-drop-zone {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: sticky;
|
||||
top: 50%;
|
||||
margin-left: 40px;
|
||||
margin-right: 8px;
|
||||
transform: translate(0, -50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
border-radius: var(--card-border-radius);
|
||||
border: 1px solid var(--separator-color);
|
||||
background: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.empty-state-text {
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-less-important);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
//.drag-time-preview {
|
||||
// position: fixed;
|
||||
// transform: translate(-50%, -100%);
|
||||
// z-index: 2000;
|
||||
// pointer-events: none;
|
||||
// margin-top: -44px;
|
||||
//
|
||||
// .time-badge {
|
||||
// background: var(--c-accent);
|
||||
// color: var(--text-on-accent);
|
||||
// padding: 8px 16px;
|
||||
// border-radius: 20px;
|
||||
// font-size: 14px;
|
||||
// font-weight: 600;
|
||||
// box-shadow: var(--whiteframe-shadow-8dp);
|
||||
// white-space: nowrap;
|
||||
// border: 2px solid var(--c-primary);
|
||||
// max-width: 320px;
|
||||
// }
|
||||
//}
|
||||
|
||||
:host ::ng-deep .grid-container {
|
||||
--schedule-time-width: 2em;
|
||||
--nr-of-days: 1;
|
||||
/* grid-template-columns uses these vars in schedule-week */
|
||||
--schedule-event-bg: var(--schedule-event-bg-in-side-panel);
|
||||
}
|
||||
|
|
@ -0,0 +1,734 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ScheduleWeekComponent } from '../schedule-week/schedule-week.component';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { selectTimelineWorkStartEndHours } from '../../config/store/global-config.reducer';
|
||||
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { mapScheduleDaysToScheduleEvents } from '../map-schedule-data/map-schedule-days-to-schedule-events';
|
||||
import { FH, SVEType } from '../schedule.const';
|
||||
import { calculateTimeFromYPosition } from '../schedule-utils';
|
||||
import { DragDropRegistry } from '@angular/cdk/drag-drop';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { TaskWithSubTasks } from '../../tasks/task.model';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { Log } from '../../../core/log';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ScheduleExternalDragService } from '../schedule-week/schedule-external-drag.service';
|
||||
import { ScheduleService } from '../schedule.service';
|
||||
import { ScheduleEvent } from '../schedule.model';
|
||||
|
||||
const DEFAULT_MIN_DURATION = 15 * 60 * 1000;
|
||||
const SCROLL_DELAY_MS = 100;
|
||||
const SCROLL_TOP_OFFSET_PX = 50;
|
||||
const MIN_PREVIEW_WIDTH_PX = 40;
|
||||
const PREVIEW_WIDTH_PADDING_PX = 10;
|
||||
const OPACITY_HIDDEN = '0';
|
||||
const OPACITY_VISIBLE = '1';
|
||||
|
||||
type DropTimeSource =
|
||||
| 'preview-top-adjusted'
|
||||
| 'pointer-top-adjusted'
|
||||
| 'top-cache'
|
||||
| 'cached';
|
||||
|
||||
interface DropTimeCalculation {
|
||||
timestamp: number | null;
|
||||
source: DropTimeSource;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'schedule-day-panel',
|
||||
standalone: true,
|
||||
imports: [ScheduleWeekComponent],
|
||||
styleUrl: './schedule-day-panel.component.scss',
|
||||
templateUrl: './schedule-day-panel.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('scheduleWeek', { read: ElementRef }) scheduleWeekRef!: ElementRef;
|
||||
@ViewChild('scheduleWeek', { read: ScheduleWeekComponent })
|
||||
scheduleWeekComponent!: ScheduleWeekComponent;
|
||||
@ViewChild('dropZone', { read: ElementRef }) dropZoneRef!: ElementRef<HTMLElement>;
|
||||
|
||||
private _store = inject(Store);
|
||||
private _dateService = inject(DateService);
|
||||
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
||||
private _dragDropRegistry = inject(DragDropRegistry);
|
||||
private _externalDragService = inject(ScheduleExternalDragService);
|
||||
private _cdr = inject(ChangeDetectorRef);
|
||||
private _ngZone = inject(NgZone);
|
||||
private _scheduleService = inject(ScheduleService);
|
||||
private _pointerUpSubscription: Subscription | null = null;
|
||||
private _activeExternalTask: TaskWithSubTasks | null = null;
|
||||
private readonly _globalPointerEvents = ['mousemove', 'touchmove'] as const;
|
||||
private readonly _globalPointerListenerOptions = { passive: true };
|
||||
|
||||
// Drag preview properties
|
||||
dragPreviewTime = signal<string | null>(null);
|
||||
isDragging = signal(false);
|
||||
private lastCalculatedTimestamp: number | null = null;
|
||||
private _dragPointerOffsetY: number | null = null;
|
||||
private _lastKnownTopY: number | null = null;
|
||||
|
||||
private _todayDateStr = toSignal(this._globalTrackingIntervalService.todayDateStr$, {
|
||||
initialValue: this._dateService.todayStr(Date.now()),
|
||||
});
|
||||
|
||||
daysToShow = computed(() => {
|
||||
const d = this._todayDateStr();
|
||||
return d ? [d] : [];
|
||||
});
|
||||
|
||||
scheduleDays = this._scheduleService.createScheduleDaysComputed(this.daysToShow);
|
||||
|
||||
private _eventsAndBeyondBudget = computed(() => {
|
||||
const days = this.scheduleDays();
|
||||
return mapScheduleDaysToScheduleEvents(days, FH);
|
||||
});
|
||||
|
||||
events = computed(() => this._eventsAndBeyondBudget().eventsFlat);
|
||||
|
||||
hasNoEvents = computed(() => {
|
||||
const evs = this.events().filter((ev) => ev.type !== SVEType.LunchBreak);
|
||||
return !evs || evs.length === 0;
|
||||
});
|
||||
|
||||
private _workStartEndHours = toSignal(
|
||||
this._store.select(selectTimelineWorkStartEndHours),
|
||||
);
|
||||
|
||||
workStartEnd = computed(() => {
|
||||
const v = this._workStartEndHours();
|
||||
return (
|
||||
v && {
|
||||
workStartRow: Math.round(FH * v.workStart) + 1,
|
||||
workEndRow: Math.round(FH * v.workEnd) + 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
currentTimeRow = computed(() => {
|
||||
// Trigger re-computation via today change
|
||||
this._todayDateStr();
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const hoursToday = hours + minutes / 60;
|
||||
return Math.round(hoursToday * FH);
|
||||
});
|
||||
|
||||
// Effect to scroll to current time when the component initializes or current time changes
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Track current time row changes to trigger auto-scroll
|
||||
this.currentTimeRow();
|
||||
this._scheduleScrollToCurrentTime();
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Listen for global pointer releases while a drag is active so we can finalize drops.
|
||||
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe((event) => {
|
||||
this._ngZone.run(() => this._handlePointerUp(event));
|
||||
});
|
||||
|
||||
// Initial scroll to current time after view initialization
|
||||
this._scheduleScrollToCurrentTime();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this._pointerUpSubscription) {
|
||||
this._pointerUpSubscription.unsubscribe();
|
||||
this._pointerUpSubscription = null;
|
||||
}
|
||||
// Ensure preview styling is cleaned up
|
||||
this._applySchedulePreviewStyling(false);
|
||||
this._activeExternalTask = null;
|
||||
}
|
||||
|
||||
onDragMove(event: MouseEvent | TouchEvent): void {
|
||||
const activeTask = this._externalDragService.activeTask();
|
||||
if (!activeTask) {
|
||||
if (this.isDragging()) {
|
||||
this._stopScheduleMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._activeExternalTask = activeTask;
|
||||
if (!this.isDragging()) {
|
||||
this._startScheduleMode();
|
||||
}
|
||||
|
||||
// Try to use drag preview position, fallback to event coordinates for touch
|
||||
const previewEl = this._getDragPreview();
|
||||
const pointer = this._getPointerPosition(event);
|
||||
if (previewEl) {
|
||||
// Desktop: use drag preview top
|
||||
const previewRect = previewEl.getBoundingClientRect();
|
||||
const topY = this._resolveTopY(previewRect.top, pointer?.y ?? null);
|
||||
const timestamp = topY != null ? this._calculateTimeFromYPosition(topY) : null;
|
||||
this.lastCalculatedTimestamp = timestamp;
|
||||
this._updateDragPreviewTime(previewEl, timestamp);
|
||||
} else {
|
||||
// Touch devices: use touch coordinates directly
|
||||
if (pointer) {
|
||||
const topY = this._resolveTopY(null, pointer.y);
|
||||
const timestamp = topY != null ? this._calculateTimeFromYPosition(topY) : null;
|
||||
this.lastCalculatedTimestamp = timestamp;
|
||||
// Update preview time without preview element for touch
|
||||
if (timestamp != null) {
|
||||
this.dragPreviewTime.set(this._formatPreviewTime(timestamp));
|
||||
} else {
|
||||
this.dragPreviewTime.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _targetDay(): string | undefined {
|
||||
const [day] = this.daysToShow();
|
||||
return day;
|
||||
}
|
||||
|
||||
private _calculateTimeFromYPosition(clientY: number): number | null {
|
||||
const containerElement = this.scheduleWeekRef?.nativeElement;
|
||||
const scheduleWeek = containerElement?.querySelector('.grid-container');
|
||||
|
||||
if (!scheduleWeek) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridRect = scheduleWeek.getBoundingClientRect();
|
||||
const targetDay = this._targetDay();
|
||||
|
||||
if (!targetDay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateTimeFromYPosition(clientY, gridRect, targetDay);
|
||||
}
|
||||
|
||||
private _calculateDropTime(
|
||||
previewRect: DOMRect | null,
|
||||
pointerY: number | null,
|
||||
): DropTimeCalculation {
|
||||
const topY = this._resolveTopY(previewRect?.top ?? null, pointerY);
|
||||
const timestampFromPosition =
|
||||
topY != null ? this._calculateTimeFromYPosition(topY) : null;
|
||||
const timestamp = timestampFromPosition ?? this.lastCalculatedTimestamp;
|
||||
const source: DropTimeSource =
|
||||
timestampFromPosition != null
|
||||
? previewRect
|
||||
? 'preview-top-adjusted'
|
||||
: pointerY != null
|
||||
? 'pointer-top-adjusted'
|
||||
: 'top-cache'
|
||||
: 'cached';
|
||||
|
||||
return { timestamp, source };
|
||||
}
|
||||
|
||||
private _handlePointerUp(event: MouseEvent | TouchEvent): void {
|
||||
if (!this.isDragging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat any pointer release while the preview is active as a potential drop on the panel.
|
||||
const task = this._activeExternalTask ?? this._externalDragService.activeTask();
|
||||
const previewRect = this._getDragPreviewRect();
|
||||
const pointer = this._getPointerPosition(event);
|
||||
const isInside = this._isEffectiveTopWithinDropZone(previewRect, pointer);
|
||||
const dropCalculation = isInside
|
||||
? this._calculateDropTime(previewRect, pointer?.y ?? null)
|
||||
: null;
|
||||
const dropTime = dropCalculation?.timestamp ?? null;
|
||||
const targetDay = this._targetDay();
|
||||
|
||||
this._stopScheduleMode();
|
||||
this._externalDragService.setActiveTask(null);
|
||||
|
||||
if (!task || !targetDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
let wasDroppedSuccessfully = false;
|
||||
|
||||
if (dropTime !== null) {
|
||||
this.lastCalculatedTimestamp = dropTime;
|
||||
const targetDate = new Date(dropTime);
|
||||
Log.log('[ScheduleDayPanel] Drop calculation:', {
|
||||
source: dropCalculation?.source ?? 'cached',
|
||||
storedTimestamp: dropTime,
|
||||
targetTime: targetDate,
|
||||
formattedTime: this._formatTime(targetDate.getHours(), targetDate.getMinutes()),
|
||||
});
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.scheduleTaskWithTime({
|
||||
task,
|
||||
dueWithTime: dropTime,
|
||||
isMoveToBacklog: false,
|
||||
}),
|
||||
);
|
||||
if (!task.timeEstimate || task.timeEstimate <= 0) {
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: task.id, changes: { timeEstimate: DEFAULT_MIN_DURATION } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
wasDroppedSuccessfully = true;
|
||||
} else if (isInside) {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task,
|
||||
day: targetDay,
|
||||
isAddToTop: true,
|
||||
}),
|
||||
);
|
||||
wasDroppedSuccessfully = true;
|
||||
}
|
||||
|
||||
// Disable snap-back animation when successfully dropped on panel
|
||||
if (wasDroppedSuccessfully) {
|
||||
this._disableSnapBackAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private _getPointerPosition(
|
||||
event: MouseEvent | TouchEvent,
|
||||
): { x: number; y: number } | null {
|
||||
if (!('touches' in event)) {
|
||||
return { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
const touch = event.touches[0] ?? event.changedTouches?.[0];
|
||||
return touch ? { x: touch.clientX, y: touch.clientY } : null;
|
||||
}
|
||||
|
||||
private _getDragPreviewRect(): DOMRect | null {
|
||||
return this._withDragPreview((preview) => preview.getBoundingClientRect());
|
||||
}
|
||||
|
||||
private _isPointerWithinDropZone(pointer: { x: number; y: number }): boolean {
|
||||
const dropZoneEl = this.dropZoneRef?.nativeElement;
|
||||
if (!dropZoneEl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dropZoneRect = dropZoneEl.getBoundingClientRect();
|
||||
return (
|
||||
pointer.x >= dropZoneRect.left &&
|
||||
pointer.x <= dropZoneRect.right &&
|
||||
pointer.y >= dropZoneRect.top &&
|
||||
pointer.y <= dropZoneRect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
private _isEffectiveTopWithinDropZone(
|
||||
rect: DOMRect | null,
|
||||
pointer: { x: number; y: number } | null,
|
||||
): boolean {
|
||||
const dropZoneEl = this.dropZoneRef?.nativeElement;
|
||||
if (!dropZoneEl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dropZoneRect = dropZoneEl.getBoundingClientRect();
|
||||
const topY = this._resolveTopY(rect?.top ?? null, pointer?.y ?? null);
|
||||
if (topY == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const horizontalOverlap = rect
|
||||
? this._isPreviewHorizontallyAligned(rect, dropZoneRect)
|
||||
: pointer
|
||||
? this._isPointerHorizontallyAligned(pointer, dropZoneRect)
|
||||
: false;
|
||||
|
||||
return horizontalOverlap && topY >= dropZoneRect.top && topY <= dropZoneRect.bottom;
|
||||
}
|
||||
|
||||
private _withDragPreview<T>(handler: (preview: HTMLElement) => T): T | null {
|
||||
const previewEl = this._getDragPreview();
|
||||
if (!previewEl) {
|
||||
return null;
|
||||
}
|
||||
return handler(previewEl);
|
||||
}
|
||||
|
||||
private _getDragPreview(): HTMLElement | null {
|
||||
return document.querySelector('.cdk-drag-preview') as HTMLElement | null;
|
||||
}
|
||||
|
||||
private _formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private _formatPreviewTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return this._formatTime(date.getHours(), date.getMinutes());
|
||||
}
|
||||
|
||||
private _resolveTopY(
|
||||
previewTop: number | null,
|
||||
pointerY: number | null,
|
||||
): number | null {
|
||||
if (pointerY != null && previewTop != null && this._dragPointerOffsetY == null) {
|
||||
this._dragPointerOffsetY = pointerY - previewTop;
|
||||
}
|
||||
|
||||
if (pointerY != null && this._dragPointerOffsetY != null) {
|
||||
const adjustedTop = pointerY - this._dragPointerOffsetY;
|
||||
this._lastKnownTopY = adjustedTop;
|
||||
return adjustedTop;
|
||||
}
|
||||
|
||||
if (previewTop != null) {
|
||||
this._lastKnownTopY = previewTop;
|
||||
return previewTop;
|
||||
}
|
||||
|
||||
if (pointerY != null) {
|
||||
return pointerY;
|
||||
}
|
||||
|
||||
return this._lastKnownTopY;
|
||||
}
|
||||
|
||||
private _isPreviewHorizontallyAligned(rect: DOMRect, dropZoneRect: DOMRect): boolean {
|
||||
return rect.right >= dropZoneRect.left && rect.left <= dropZoneRect.right;
|
||||
}
|
||||
|
||||
private _isPointerHorizontallyAligned(
|
||||
pointer: { x: number; y: number },
|
||||
dropZoneRect: DOMRect,
|
||||
): boolean {
|
||||
return pointer.x >= dropZoneRect.left && pointer.x <= dropZoneRect.right;
|
||||
}
|
||||
|
||||
private _scheduleScrollToCurrentTime(): void {
|
||||
setTimeout(() => {
|
||||
this._scrollToCurrentTime();
|
||||
}, SCROLL_DELAY_MS);
|
||||
}
|
||||
|
||||
private _findScrollContainer(): Element | null {
|
||||
const selectors = ['.side-inner', '.right-panel', '[class*="panel"]'];
|
||||
const el = this.scheduleWeekRef.nativeElement;
|
||||
|
||||
for (const selector of selectors) {
|
||||
const container = el.closest(selector);
|
||||
if (container) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _scrollToCurrentTime(): void {
|
||||
if (!this.scheduleWeekRef?.nativeElement) {
|
||||
Log.warn('[ScheduleDayPanel] No scheduleWeekRef available');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeElement =
|
||||
this.scheduleWeekRef.nativeElement.querySelector('.current-time');
|
||||
if (!currentTimeElement) {
|
||||
Log.warn('[ScheduleDayPanel] Current time element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollContainer = this._findScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
Log.warn('[ScheduleDayPanel] No scrollable container found');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log('[ScheduleDayPanel] Found scroll container:', scrollContainer.className);
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementRect = currentTimeElement.getBoundingClientRect();
|
||||
const relativePosition = elementRect.top - containerRect.top;
|
||||
const targetScrollTop =
|
||||
scrollContainer.scrollTop + relativePosition - SCROLL_TOP_OFFSET_PX;
|
||||
|
||||
Log.log('[ScheduleDayPanel] Scrolling to position:', Math.max(0, targetScrollTop));
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
});
|
||||
}
|
||||
|
||||
private _updateDragPreviewTime(previewEl: HTMLElement, timestamp: number | null): void {
|
||||
if (timestamp == null) {
|
||||
this.dragPreviewTime.set(null);
|
||||
this._clearTimeBadgeText(previewEl);
|
||||
return;
|
||||
}
|
||||
const timeStr = this._formatPreviewTime(timestamp);
|
||||
this.dragPreviewTime.set(timeStr);
|
||||
this._ensureTimeBadge(previewEl).textContent = timeStr;
|
||||
}
|
||||
|
||||
private _startScheduleMode(): void {
|
||||
if (this.isDragging()) return;
|
||||
this.isDragging.set(true);
|
||||
this._dragPointerOffsetY = null;
|
||||
this._lastKnownTopY = null;
|
||||
this._applySchedulePreviewStyling(true);
|
||||
this._cdr.markForCheck();
|
||||
this._toggleGlobalPointerListeners(true);
|
||||
|
||||
// Show custom drag preview in schedule-week component
|
||||
const task = this._activeExternalTask;
|
||||
if (task && this.scheduleWeekComponent) {
|
||||
const scheduleEvent = this._createScheduleEventFromTask(task);
|
||||
const style = this._calculateInitialPreviewStyle();
|
||||
const timestamp = this.lastCalculatedTimestamp || Date.now();
|
||||
this.scheduleWeekComponent.showExternalPreview(scheduleEvent, style, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private _stopScheduleMode(): void {
|
||||
if (!this.isDragging()) return;
|
||||
this.isDragging.set(false);
|
||||
this.dragPreviewTime.set(null);
|
||||
this.lastCalculatedTimestamp = null;
|
||||
this._dragPointerOffsetY = null;
|
||||
this._lastKnownTopY = null;
|
||||
this._activeExternalTask = null;
|
||||
this._applySchedulePreviewStyling(false);
|
||||
this._cdr.markForCheck();
|
||||
this._toggleGlobalPointerListeners(false);
|
||||
|
||||
// Hide custom drag preview in schedule-week component
|
||||
if (this.scheduleWeekComponent) {
|
||||
this.scheduleWeekComponent.hideExternalPreview();
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleGlobalPointerListeners(isEnable: boolean): void {
|
||||
for (const eventName of this._globalPointerEvents) {
|
||||
if (isEnable) {
|
||||
document.addEventListener(
|
||||
eventName,
|
||||
this._onGlobalPointerMove,
|
||||
this._globalPointerListenerOptions,
|
||||
);
|
||||
} else {
|
||||
document.removeEventListener(eventName, this._onGlobalPointerMove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _onGlobalPointerMove = (ev: MouseEvent | TouchEvent): void => {
|
||||
const pointer = this._getPointerPosition(ev);
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewRect = this._getDragPreviewRect();
|
||||
const isPointerInsideDropZone = this._isPointerWithinDropZone(pointer);
|
||||
|
||||
if (!isPointerInsideDropZone) {
|
||||
// If pointer is not over the side panel/drop zone anymore, leave schedule mode
|
||||
this._ngZone.run(() => this._stopScheduleMode());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update time calculation continuously while dragging over the drop zone
|
||||
// This is especially important for touch devices
|
||||
this._ngZone.run(() => {
|
||||
const topY = this._resolveTopY(previewRect?.top ?? null, pointer.y);
|
||||
const timestamp = topY != null ? this._calculateTimeFromYPosition(topY) : null;
|
||||
this.lastCalculatedTimestamp = timestamp;
|
||||
|
||||
if (timestamp != null) {
|
||||
this.dragPreviewTime.set(this._formatPreviewTime(timestamp));
|
||||
// Update badge on preview element if it exists (desktop)
|
||||
const previewEl = this._getDragPreview();
|
||||
if (previewEl) {
|
||||
const timeBadge = this._ensureTimeBadge(previewEl);
|
||||
timeBadge.textContent = this._formatPreviewTime(timestamp);
|
||||
}
|
||||
|
||||
// Update custom preview position in schedule-week component
|
||||
if (this.scheduleWeekComponent) {
|
||||
const style = this._calculatePreviewStyleFromTime(timestamp);
|
||||
if (style) {
|
||||
this.scheduleWeekComponent.updateExternalPreview(style, timestamp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.dragPreviewTime.set(null);
|
||||
}
|
||||
this._cdr.markForCheck();
|
||||
});
|
||||
};
|
||||
|
||||
// we're mutating the dom directly here to add some styling to the drag preview, since it is the most efficient way to do it
|
||||
private _applySchedulePreviewStyling(isEnable: boolean): void {
|
||||
this._withDragPreview((previewEl) => {
|
||||
const tagName = previewEl.tagName.toLowerCase();
|
||||
if (tagName === 'task') {
|
||||
this._applyTaskPreviewStyling(previewEl, isEnable);
|
||||
} else if (tagName === 'schedule-event') {
|
||||
this._applyScheduleEventPreviewStyling(previewEl, isEnable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _applyTaskPreviewStyling(previewEl: HTMLElement, isEnable: boolean): void {
|
||||
if (isEnable) {
|
||||
previewEl.classList.add('as-schedule-event-preview');
|
||||
previewEl.style.opacity = OPACITY_HIDDEN;
|
||||
this._setPreviewWidth(previewEl);
|
||||
const timeBadge = this._ensureTimeBadge(previewEl);
|
||||
timeBadge.textContent = this.dragPreviewTime() ?? '';
|
||||
} else {
|
||||
previewEl.classList.remove('as-schedule-event-preview');
|
||||
previewEl.style.opacity = OPACITY_VISIBLE;
|
||||
previewEl.style.removeProperty('width');
|
||||
this._removeTimeBadge(previewEl);
|
||||
}
|
||||
}
|
||||
|
||||
private _setPreviewWidth(previewEl: HTMLElement): void {
|
||||
const containerElement = this.scheduleWeekRef?.nativeElement as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const day = this._targetDay();
|
||||
|
||||
if (!containerElement || !day) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colEl = containerElement.querySelector(
|
||||
`.grid-container .col[data-day="${day}"]`,
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!colEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colRect = colEl.getBoundingClientRect();
|
||||
previewEl.style.width = `${Math.max(MIN_PREVIEW_WIDTH_PX, colRect.width - PREVIEW_WIDTH_PADDING_PX)}px`;
|
||||
}
|
||||
|
||||
private _ensureTimeBadge(previewEl: HTMLElement): HTMLElement {
|
||||
let timeBadge = previewEl.querySelector(
|
||||
'.drag-preview-time-badge',
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!timeBadge) {
|
||||
timeBadge = document.createElement('div');
|
||||
timeBadge.className = 'drag-preview-time-badge';
|
||||
previewEl.appendChild(timeBadge);
|
||||
}
|
||||
|
||||
return timeBadge;
|
||||
}
|
||||
|
||||
private _clearTimeBadgeText(previewEl: HTMLElement): void {
|
||||
const timeBadge = previewEl.querySelector(
|
||||
'.drag-preview-time-badge',
|
||||
) as HTMLElement | null;
|
||||
if (timeBadge) {
|
||||
timeBadge.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
private _removeTimeBadge(previewEl: HTMLElement): void {
|
||||
const timeBadge = previewEl.querySelector('.drag-preview-time-badge');
|
||||
if (timeBadge) {
|
||||
timeBadge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private _applyScheduleEventPreviewStyling(
|
||||
previewEl: HTMLElement,
|
||||
isEnable: boolean,
|
||||
): void {
|
||||
previewEl.style.opacity = isEnable ? OPACITY_HIDDEN : OPACITY_VISIBLE;
|
||||
}
|
||||
|
||||
private _createScheduleEventFromTask(task: TaskWithSubTasks): ScheduleEvent {
|
||||
const timeEstimate = task.timeEstimate || DEFAULT_MIN_DURATION;
|
||||
const timeInHours = timeEstimate / (60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
type: SVEType.Task,
|
||||
style: '',
|
||||
startHours: 0,
|
||||
timeLeftInHours: timeInHours,
|
||||
isCloseToOthersFirst: false,
|
||||
isCloseToOthers: false,
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
||||
private _calculateInitialPreviewStyle(): string {
|
||||
// Start with a default position (e.g., row 1, column 2)
|
||||
const row = 1;
|
||||
const col = 2;
|
||||
const rowSpan = this._calculateRowSpanFromTask(this._activeExternalTask);
|
||||
return `grid-row: ${row} / span ${rowSpan}; grid-column: ${col} / span 1`;
|
||||
}
|
||||
|
||||
private _calculatePreviewStyleFromTime(timestamp: number): string | null {
|
||||
const gridContainer = this.scheduleWeekRef?.nativeElement?.querySelector(
|
||||
'.grid-container',
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!gridContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const hoursDecimal = hours + minutes / 60;
|
||||
|
||||
// Calculate row based on time (FH rows per hour)
|
||||
const row = Math.round(hoursDecimal * FH) + 1;
|
||||
const col = 2; // First day column
|
||||
const rowSpan = this._calculateRowSpanFromTask(this._activeExternalTask);
|
||||
|
||||
return `grid-row: ${row} / span ${rowSpan}; grid-column: ${col} / span 1`;
|
||||
}
|
||||
|
||||
private _calculateRowSpanFromTask(task: TaskWithSubTasks | null): number {
|
||||
if (!task) {
|
||||
return 6; // Default fallback
|
||||
}
|
||||
const timeEstimate = task.timeEstimate || DEFAULT_MIN_DURATION;
|
||||
const timeInHours = timeEstimate / (60 * 60 * 1000);
|
||||
return Math.max(Math.round(timeInHours * FH), 1);
|
||||
}
|
||||
|
||||
private _disableSnapBackAnimation(): void {
|
||||
// Immediately hide the drag preview to prevent snap-back animation
|
||||
const previewEl = this._getDragPreview();
|
||||
if (previewEl) {
|
||||
previewEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,3 +98,5 @@
|
|||
<div class="resize-grip"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-content></ng-content>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
--split-extra-border-radius: 20px;
|
||||
--margin-bottom: 6px;
|
||||
--margin-right: 10px;
|
||||
--offset: 40px;
|
||||
--offset: 20px;
|
||||
--offset-short-event: 15px;
|
||||
--standard-border-radius: 6px;
|
||||
|
||||
border-radius: 3px;
|
||||
border-radius: var(--standard-border-radius);
|
||||
margin: 0 var(--margin-right) var(--margin-bottom) 0;
|
||||
scroll-margin-top: 120px;
|
||||
scroll-padding-top: 120px;
|
||||
|
|
@ -161,7 +162,6 @@
|
|||
}
|
||||
|
||||
:host-context(.is-not-dragging) {
|
||||
:host.ScheduledTask,
|
||||
:host.CalendarEvent,
|
||||
:host.RepeatProjection,
|
||||
:host.RepeatProjectionSplit,
|
||||
|
|
@ -212,11 +212,6 @@
|
|||
:host.very-short-event & {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
:host-context(.is-not-dragging):hover & {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent);
|
||||
}
|
||||
}
|
||||
|
||||
:host > * {
|
||||
|
|
@ -335,7 +330,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dragging) {
|
||||
:host-context(.is-dragging.isShiftKeyPressed) {
|
||||
:host.TaskPlannedForDay,
|
||||
:host.SplitTaskPlannedForDay,
|
||||
:host.Task,
|
||||
|
|
@ -344,7 +339,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
:host-context(.isDarkTheme.is-dragging) {
|
||||
:host-context(.isDarkTheme.is-dragging.isShiftKeyPressed) {
|
||||
:host.TaskPlannedForDay,
|
||||
:host.SplitTaskPlannedForDay,
|
||||
:host.Task,
|
||||
|
|
@ -354,37 +349,46 @@
|
|||
}
|
||||
|
||||
:host.drag-over {
|
||||
transform: translateY(var(--offset));
|
||||
border: 3px solid yellow;
|
||||
z-index: 3;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
transform: translateY(calc(-1 * var(--offset)));
|
||||
top: calc(-1 * var(--margin-bottom));
|
||||
height: calc(var(--offset) + var(--margin-bottom));
|
||||
left: 0;
|
||||
right: calc(-1 * (var(--margin-right) + 4px));
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&.very-short-event {
|
||||
transform: translateY(var(--offset-short-event));
|
||||
|
||||
&:after {
|
||||
transform: translateY(calc(-1 * var(--offset-short-event)));
|
||||
height: calc(var(--offset-short-event) + var(--margin-bottom));
|
||||
}
|
||||
}
|
||||
transform: translateX(16px);
|
||||
//transform: translateY(var(--offset));
|
||||
//
|
||||
//&:after {
|
||||
// content: '';
|
||||
// position: absolute;
|
||||
// transform: translateY(calc(-1 * var(--offset)));
|
||||
// top: calc(-1 * var(--margin-bottom));
|
||||
// height: calc(var(--offset) + var(--margin-bottom));
|
||||
// left: 0;
|
||||
// right: calc(-1 * (var(--margin-right) + 4px));
|
||||
// z-index: 3;
|
||||
//}
|
||||
//
|
||||
//&.very-short-event {
|
||||
// transform: translateY(var(--offset-short-event));
|
||||
//
|
||||
// &:after {
|
||||
// transform: translateY(calc(-1 * var(--offset-short-event)));
|
||||
// height: calc(var(--offset-short-event) + var(--margin-bottom));
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
:host.draggable {
|
||||
cursor: grab;
|
||||
|
||||
// Ensure the ::before pseudo-element also shows the cursor
|
||||
&::before {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
|
||||
&::before {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { TaskWithSubTasks } from '../../tasks/task.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScheduleExternalDragService {
|
||||
private readonly _activeTask = signal<TaskWithSubTasks | null>(null);
|
||||
|
||||
activeTask(): TaskWithSubTasks | null {
|
||||
return this._activeTask();
|
||||
}
|
||||
|
||||
setActiveTask(task: TaskWithSubTasks | null): void {
|
||||
this._activeTask.set(task);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,778 @@
|
|||
import { CdkDragMove, CdkDragRelease, CdkDragStart } from '@angular/cdk/drag-drop';
|
||||
import { inject, Injectable, Signal, signal } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import {
|
||||
FH,
|
||||
SCHEDULE_TASK_MIN_DURATION_IN_MS,
|
||||
SVEType,
|
||||
T_ID_PREFIX,
|
||||
} from '../schedule.const';
|
||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { TaskCopy, TaskReminderOptionId } from '../../tasks/task.model';
|
||||
import { calculateTimeFromYPosition } from '../schedule-utils';
|
||||
import { IS_TOUCH_PRIMARY } from '../../../util/is-mouse-primary';
|
||||
import type { DragPreviewContext } from './schedule-week-drag.types';
|
||||
import type { ScheduleEvent } from '../schedule.model';
|
||||
import { selectTodayTagTaskIds } from '../../tag/store/tag.reducer';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
interface PointerPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const DRAG_CLONE_CLASS = 'drag-clone';
|
||||
const DRAG_OVER_CLASS = 'drag-over';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleWeekDragService {
|
||||
// Central drag state handler so the component can remain mostly declarative.
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
private readonly _isShiftMode = signal(false);
|
||||
readonly isShiftMode: Signal<boolean> = this._isShiftMode.asReadonly();
|
||||
|
||||
private readonly _dragPreviewContext = signal<DragPreviewContext>(null);
|
||||
readonly dragPreviewContext: Signal<DragPreviewContext> =
|
||||
this._dragPreviewContext.asReadonly();
|
||||
|
||||
private readonly _dragPreviewStyle = signal<string | null>(null);
|
||||
readonly dragPreviewStyle: Signal<string | null> = this._dragPreviewStyle.asReadonly();
|
||||
|
||||
private readonly _currentDragEvent = signal<ScheduleEvent | null>(null);
|
||||
readonly currentDragEvent: Signal<ScheduleEvent | null> =
|
||||
this._currentDragEvent.asReadonly();
|
||||
|
||||
private readonly _isDragging = signal(false);
|
||||
readonly isDragging: Signal<boolean> = this._isDragging.asReadonly();
|
||||
|
||||
private readonly _showShiftKeyInfo = signal(false);
|
||||
readonly showShiftKeyInfo: Signal<boolean> = this._showShiftKeyInfo.asReadonly();
|
||||
|
||||
// Track the task ID being hovered over for reorder preview
|
||||
private readonly _dragOverTaskId = signal<string | null>(null);
|
||||
readonly dragOverTaskId: Signal<string | null> = this._dragOverTaskId.asReadonly();
|
||||
|
||||
private _prevDragOverEl: HTMLElement | null = null;
|
||||
private _dragCloneEl: HTMLElement | null = null;
|
||||
private _lastDropCol: HTMLElement | null = null;
|
||||
private _lastDropScheduleEvent: HTMLElement | null = null;
|
||||
private _lastPointerPosition: PointerPosition | null = null;
|
||||
private _lastCalculatedTimestamp: number | null = null;
|
||||
private _shiftInfoTimeoutId: number | undefined;
|
||||
// Use accessors instead of direct references to prevent holding stale DOM nodes
|
||||
// between Angular re-renders. This ensures we always query the current DOM state.
|
||||
private _gridContainerAccessor: (() => HTMLElement | null) | null = null;
|
||||
private _daysToShowAccessor: (() => readonly string[]) | null = null;
|
||||
|
||||
destroy(): void {
|
||||
this._clearShiftInfoTimeout();
|
||||
this._resetDragRelatedVars();
|
||||
this._gridContainerAccessor = null;
|
||||
this._daysToShowAccessor = null;
|
||||
}
|
||||
|
||||
// External preview control methods for schedule-day-panel integration
|
||||
showExternalPreview(event: ScheduleEvent, style: string, timestamp: number): void {
|
||||
this._isDragging.set(true);
|
||||
this._currentDragEvent.set(event);
|
||||
this._dragPreviewStyle.set(style);
|
||||
this._dragPreviewContext.set({ kind: 'time', timestamp });
|
||||
}
|
||||
|
||||
updateExternalPreview(style: string, timestamp: number): void {
|
||||
if (!this._isDragging()) {
|
||||
return;
|
||||
}
|
||||
this._dragPreviewStyle.set(style);
|
||||
this._dragPreviewContext.set({ kind: 'time', timestamp });
|
||||
}
|
||||
|
||||
hideExternalPreview(): void {
|
||||
this._isDragging.set(false);
|
||||
this._currentDragEvent.set(null);
|
||||
this._dragPreviewStyle.set(null);
|
||||
this._dragPreviewContext.set(null);
|
||||
}
|
||||
|
||||
setGridContainer(accessor: () => HTMLElement | null): void {
|
||||
// Resolve the container lazily so we don't hold on to stale DOM nodes between renders.
|
||||
this._gridContainerAccessor = accessor;
|
||||
}
|
||||
|
||||
setDaysToShowAccessor(accessor: () => readonly string[]): void {
|
||||
this._daysToShowAccessor = accessor;
|
||||
}
|
||||
|
||||
setShiftMode(isShiftMode: boolean): void {
|
||||
if (this._isShiftMode() !== isShiftMode) {
|
||||
this._isShiftMode.set(isShiftMode);
|
||||
}
|
||||
}
|
||||
|
||||
handleDragStarted(ev: CdkDragStart<ScheduleEvent>): void {
|
||||
this._isDragging.set(true);
|
||||
this._currentDragEvent.set(ev.source.data);
|
||||
this._dragPreviewContext.set(null);
|
||||
this._dragOverTaskId.set(null);
|
||||
this._lastDropCol = null;
|
||||
this._lastDropScheduleEvent = null;
|
||||
this._lastPointerPosition = null;
|
||||
this._lastCalculatedTimestamp = null;
|
||||
|
||||
// Show shift key tooltip on non-touch devices to educate users about the feature,
|
||||
// then auto-hide after 3 seconds to avoid cluttering the interface.
|
||||
if (!IS_TOUCH_PRIMARY) {
|
||||
this._showShiftKeyInfo.set(true);
|
||||
this._shiftInfoTimeoutId = window.setTimeout(() => {
|
||||
this._showShiftKeyInfo.set(false);
|
||||
this._shiftInfoTimeoutId = undefined;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const nativeEl = ev.source.element.nativeElement;
|
||||
|
||||
// Hide the original drag-preview element so only our custom preview is visible
|
||||
nativeEl.style.opacity = '0';
|
||||
|
||||
const cloneEl = this._dragCloneEl;
|
||||
if (cloneEl) {
|
||||
cloneEl.remove();
|
||||
this._dragCloneEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleDragMoved(ev: CdkDragMove<ScheduleEvent>): void {
|
||||
if (!this._isDragging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable pointer events on the dragged element so elementFromPoint
|
||||
// can detect what's underneath it, not the drag preview itself.
|
||||
ev.source.element.nativeElement.style.pointerEvents = 'none';
|
||||
const pointer: PointerPosition = {
|
||||
x: ev.pointerPosition.x,
|
||||
y: ev.pointerPosition.y,
|
||||
};
|
||||
const targetEl = this._updatePointerCaches(pointer);
|
||||
if (!targetEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gridContainer = this._gridContainer();
|
||||
if (!gridContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
const targetDay = this._getDayUnderPointer(pointer.x, pointer.y);
|
||||
const isWithinGrid = this._isWithinGrid(pointer, gridRect);
|
||||
|
||||
if (this.isShiftMode()) {
|
||||
this._handleShiftDragMove(targetEl, pointer, gridRect, targetDay, isWithinGrid);
|
||||
} else {
|
||||
this._handleTimeDragMove(pointer, gridRect, targetDay, isWithinGrid);
|
||||
}
|
||||
}
|
||||
|
||||
handleDragReleased(ev: CdkDragRelease): void {
|
||||
const prevEl = this._prevDragOverEl;
|
||||
if (prevEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
this._prevDragOverEl = null;
|
||||
}
|
||||
|
||||
const dropPoint = this._lastPointerPosition ?? this._extractDropPoint(ev.event);
|
||||
const cloneEl = this._dragCloneEl;
|
||||
if (cloneEl) {
|
||||
cloneEl.remove();
|
||||
this._dragCloneEl = null;
|
||||
}
|
||||
|
||||
this._isDragging.set(false);
|
||||
const nativeEl = ev.source.element.nativeElement;
|
||||
|
||||
this._dragPreviewContext.set(null);
|
||||
this._currentDragEvent.set(null);
|
||||
this._dragPreviewStyle.set(null);
|
||||
this._dragOverTaskId.set(null);
|
||||
|
||||
// make original element visible again and re-enable pointer events
|
||||
nativeEl.style.opacity = '';
|
||||
nativeEl.style.pointerEvents = '';
|
||||
|
||||
const { columnTarget, scheduleEventTarget } = this._resolveDropTargets(ev);
|
||||
const sourceEvent = ev.source.data;
|
||||
const task = this._pluckTaskFromEvent(sourceEvent);
|
||||
const sourceTaskId = nativeEl.id.replace(T_ID_PREFIX, '');
|
||||
const targetTaskId = scheduleEventTarget
|
||||
? scheduleEventTarget.id.replace(T_ID_PREFIX, '')
|
||||
: '';
|
||||
const canMoveBefore =
|
||||
!!scheduleEventTarget &&
|
||||
sourceTaskId.length > 0 &&
|
||||
targetTaskId.length > 0 &&
|
||||
sourceTaskId !== targetTaskId;
|
||||
|
||||
// Guard: nothing to do without a task
|
||||
if (!task) {
|
||||
this._resetDragRelatedVars();
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
ev.source.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatchMoveBefore = (): void => {
|
||||
this._store.dispatch(
|
||||
PlannerActions.moveBeforeTask({
|
||||
fromTask: task,
|
||||
toTaskId: targetTaskId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Handle drop scenarios in priority order using if-else chain:
|
||||
if (this.isShiftMode() && canMoveBefore) {
|
||||
// 1. Shift mode + hovering over another task → reorder
|
||||
dispatchMoveBefore();
|
||||
} else if (columnTarget) {
|
||||
// 2. Dropped on a column → schedule or plan for day
|
||||
const wasHandled = this._handleColumnDrop({ task, columnTarget, dropPoint });
|
||||
// 3. Column drop failed but hovering over task → reorder as fallback
|
||||
if (!wasHandled && canMoveBefore) {
|
||||
dispatchMoveBefore();
|
||||
}
|
||||
} else if (canMoveBefore) {
|
||||
// 4. No column but hovering over another task → reorder
|
||||
dispatchMoveBefore();
|
||||
} else if (dropPoint && this._isOutsideGrid(dropPoint)) {
|
||||
// 5. Dropped outside grid → unschedule and remove from today
|
||||
this._handleUnschedule(task, sourceEvent);
|
||||
}
|
||||
|
||||
// Clear timestamp and other drag-related vars AFTER drop is processed
|
||||
this._resetDragRelatedVars();
|
||||
// reset to original (now new) position
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
ev.source.reset();
|
||||
}
|
||||
|
||||
refreshPreviewForCurrentPointer(): void {
|
||||
if (!this._isDragging()) {
|
||||
return;
|
||||
}
|
||||
const pointer = this._lastPointerPosition;
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
const gridContainer = this._gridContainer();
|
||||
if (!gridContainer) {
|
||||
return;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
const targetDay = this._getDayUnderPointer(pointer.x, pointer.y);
|
||||
const targetEl =
|
||||
this._updatePointerCaches(pointer) ??
|
||||
this._lastDropCol ??
|
||||
this._lastDropScheduleEvent;
|
||||
const isWithinGrid = this._isWithinGrid(pointer, gridRect);
|
||||
|
||||
if (this.isShiftMode()) {
|
||||
if (targetEl) {
|
||||
this._handleShiftDragMove(targetEl, pointer, gridRect, targetDay, isWithinGrid);
|
||||
} else {
|
||||
this._dragPreviewContext.set(null);
|
||||
}
|
||||
} else {
|
||||
this._handleTimeDragMove(pointer, gridRect, targetDay, isWithinGrid);
|
||||
}
|
||||
}
|
||||
|
||||
private _gridContainer(): HTMLElement | null {
|
||||
if (!this._gridContainerAccessor) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return this._gridContainerAccessor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _daysToShow(): readonly string[] {
|
||||
if (!this._daysToShowAccessor) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
// `daysToShow` is an Angular signal; invoking the accessor keeps us in sync.
|
||||
return this._daysToShowAccessor() ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private _clearShiftInfoTimeout(): void {
|
||||
if (this._shiftInfoTimeoutId) {
|
||||
window.clearTimeout(this._shiftInfoTimeoutId);
|
||||
this._shiftInfoTimeoutId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetDragRelatedVars(): void {
|
||||
this._lastDropCol = null;
|
||||
this._lastDropScheduleEvent = null;
|
||||
this._lastPointerPosition = null;
|
||||
this._lastCalculatedTimestamp = null;
|
||||
this._prevDragOverEl = null;
|
||||
this._dragCloneEl = null;
|
||||
}
|
||||
|
||||
// Prevent treating drag preview elements as valid drop targets,
|
||||
// since they're just visual indicators and not actual schedule slots.
|
||||
private _isPreviewElement(element: Element | null): boolean {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
element.classList.contains(DRAG_CLONE_CLASS) ||
|
||||
element.classList.contains('custom-drag-preview') ||
|
||||
element.classList.contains('cdk-drag-preview') ||
|
||||
!!element.closest('.cdk-drag-preview')
|
||||
);
|
||||
}
|
||||
|
||||
private _sanitizeScheduleEventTarget(target: HTMLElement | null): HTMLElement | null {
|
||||
if (!target || this._isPreviewElement(target)) {
|
||||
return null;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private _updatePointerCaches(pointer: PointerPosition): HTMLElement | null {
|
||||
// Cache drop targets during drag moves so we can reuse them on release
|
||||
// without expensive re-querying, especially when pointer hasn't moved.
|
||||
this._lastPointerPosition = pointer;
|
||||
const elementsAtPoint = document.elementsFromPoint(pointer.x, pointer.y);
|
||||
const interactiveElements = elementsAtPoint.filter(
|
||||
(el): el is HTMLElement => el instanceof HTMLElement && !this._isPreviewElement(el),
|
||||
);
|
||||
|
||||
if (interactiveElements.length) {
|
||||
this._lastDropCol =
|
||||
interactiveElements.find((el) => el.classList.contains('col')) || null;
|
||||
const scheduleEventCandidate =
|
||||
interactiveElements.find((el) => el.tagName.toLowerCase() === 'schedule-event') ||
|
||||
null;
|
||||
const sanitizedScheduleEvent =
|
||||
this._sanitizeScheduleEventTarget(scheduleEventCandidate);
|
||||
this._lastDropScheduleEvent = sanitizedScheduleEvent;
|
||||
const targetEl = sanitizedScheduleEvent ?? interactiveElements[0];
|
||||
return this._isPreviewElement(targetEl) ? null : targetEl;
|
||||
}
|
||||
|
||||
const fallback = document.elementFromPoint(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
) as HTMLElement | null;
|
||||
if (fallback && !this._isPreviewElement(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _handleShiftDragMove(
|
||||
targetEl: HTMLElement,
|
||||
pointer: PointerPosition,
|
||||
gridRect: DOMRect,
|
||||
targetDay: string,
|
||||
isWithinGrid: boolean,
|
||||
): void {
|
||||
this._lastCalculatedTimestamp = null;
|
||||
|
||||
if (isWithinGrid) {
|
||||
// Create preview (we don't use the timestamp in shift mode, but still create the visual)
|
||||
this._createDragPreview(targetDay, pointer.y, gridRect);
|
||||
|
||||
if (targetEl.classList.contains('col')) {
|
||||
this._dragPreviewContext.set({
|
||||
kind: 'shift-column',
|
||||
day: targetDay,
|
||||
isEndOfDay: targetEl.classList.contains('end-of-day'),
|
||||
});
|
||||
this._dragOverTaskId.set(null);
|
||||
} else {
|
||||
this._dragPreviewContext.set(null);
|
||||
// Extract task ID from hovered schedule event element
|
||||
const isTaskElement =
|
||||
targetEl.classList.contains(SVEType.Task) ||
|
||||
targetEl.classList.contains(SVEType.SplitTask) ||
|
||||
targetEl.classList.contains(SVEType.SplitTaskPlannedForDay) ||
|
||||
targetEl.classList.contains(SVEType.TaskPlannedForDay);
|
||||
|
||||
if (isTaskElement && targetEl.id.startsWith(T_ID_PREFIX)) {
|
||||
const taskId = targetEl.id.replace(T_ID_PREFIX, '');
|
||||
this._dragOverTaskId.set(taskId);
|
||||
this._dragPreviewContext.set({ kind: 'shift-task', taskId });
|
||||
} else {
|
||||
this._dragOverTaskId.set(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._dragPreviewStyle.set(null);
|
||||
this._dragPreviewContext.set(null);
|
||||
this._dragOverTaskId.set(null);
|
||||
}
|
||||
|
||||
const prevEl = this._prevDragOverEl;
|
||||
if (prevEl && prevEl !== targetEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
}
|
||||
if (prevEl !== targetEl) {
|
||||
this._prevDragOverEl = targetEl;
|
||||
if (
|
||||
targetEl.classList.contains(SVEType.Task) ||
|
||||
targetEl.classList.contains(SVEType.SplitTask) ||
|
||||
targetEl.classList.contains(SVEType.SplitTaskPlannedForDay) ||
|
||||
targetEl.classList.contains(SVEType.TaskPlannedForDay) ||
|
||||
targetEl.classList.contains('col')
|
||||
) {
|
||||
targetEl.classList.add(DRAG_OVER_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTimeDragMove(
|
||||
pointer: PointerPosition,
|
||||
gridRect: DOMRect,
|
||||
targetDay: string,
|
||||
isWithinGrid: boolean,
|
||||
): void {
|
||||
const prevEl = this._prevDragOverEl;
|
||||
if (prevEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
this._prevDragOverEl = null;
|
||||
}
|
||||
|
||||
// Clear drag-over task ID in time mode
|
||||
this._dragOverTaskId.set(null);
|
||||
|
||||
if (isWithinGrid) {
|
||||
// Create preview and get the adjusted timestamp that accounts for cursor centering
|
||||
const adjustedTimestamp = this._createDragPreview(targetDay, pointer.y, gridRect);
|
||||
|
||||
// Cache adjusted timestamp for drop operations
|
||||
this._lastCalculatedTimestamp = adjustedTimestamp;
|
||||
|
||||
if (adjustedTimestamp) {
|
||||
this._dragPreviewContext.set({ kind: 'time', timestamp: adjustedTimestamp });
|
||||
} else {
|
||||
this._dragPreviewContext.set(null);
|
||||
}
|
||||
} else {
|
||||
// Show different label based on whether task has scheduled time
|
||||
const task = this._pluckTaskFromEvent(this._currentDragEvent());
|
||||
const label = task?.dueWithTime ? '✖ Unschedule Time' : '✖ Unschedule from Today';
|
||||
this._dragPreviewContext.set({ kind: 'unschedule', label });
|
||||
this._lastCalculatedTimestamp = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _isWithinGrid(pointer: PointerPosition, gridRect: DOMRect): boolean {
|
||||
return (
|
||||
pointer.y >= gridRect.top &&
|
||||
pointer.y <= gridRect.bottom &&
|
||||
pointer.x >= gridRect.left &&
|
||||
pointer.x <= gridRect.right
|
||||
);
|
||||
}
|
||||
|
||||
private _createDragPreview(
|
||||
targetDay: string,
|
||||
pointerY: number,
|
||||
gridRect: DOMRect,
|
||||
): number | null {
|
||||
const relativeY = pointerY - gridRect.top;
|
||||
const totalRows = 24 * FH;
|
||||
const rowHeight = gridRect.height / totalRows;
|
||||
const rowSpan = this._calculateRowSpan(this._currentDragEvent());
|
||||
|
||||
// Center the cursor on the preview by offsetting by half the rowSpan.
|
||||
// This makes the cursor appear more centered on the preview element
|
||||
// rather than at the very top edge.
|
||||
const rowOffset = Math.floor(rowSpan / 2);
|
||||
const row = Math.max(1, Math.round(relativeY / rowHeight) + 1 - rowOffset);
|
||||
|
||||
const dayIndex = this._daysToShow().findIndex((day) => day === targetDay);
|
||||
const col = dayIndex + 2;
|
||||
|
||||
const gridStyle = [
|
||||
`grid-row: ${row} / span ${rowSpan}`,
|
||||
`grid-column: ${col} / span 1`,
|
||||
].join('; ');
|
||||
|
||||
this._dragPreviewStyle.set(gridStyle);
|
||||
|
||||
// Calculate the adjusted timestamp based on where the preview's top edge is positioned,
|
||||
// not where the cursor is. This ensures the time badge and drop time match the visual.
|
||||
const offsetRows = row - 1;
|
||||
const offsetY = offsetRows * rowHeight;
|
||||
const adjustedY = gridRect.top + offsetY;
|
||||
return calculateTimeFromYPosition(adjustedY, gridRect, targetDay);
|
||||
}
|
||||
|
||||
// Calculate preview height based on task duration so users can see
|
||||
// how much space the task will occupy before dropping it.
|
||||
private _calculateRowSpan(event: ScheduleEvent | null): number {
|
||||
if (!event) {
|
||||
return 6;
|
||||
}
|
||||
const task = this._pluckTaskFromEvent(event);
|
||||
if (task?.timeEstimate) {
|
||||
const timeInHours = task.timeEstimate / (60 * 60 * 1000);
|
||||
return Math.max(Math.round(timeInHours * FH), 1);
|
||||
}
|
||||
return Math.max(Math.round(event.timeLeftInHours * FH), 1);
|
||||
}
|
||||
|
||||
private _getDayUnderPointer(x: number, y: number): string {
|
||||
const elementsAtPoint = document.elementsFromPoint(x, y) as HTMLElement[];
|
||||
const colEl = elementsAtPoint.find(
|
||||
(el) => el?.classList?.contains('col') && el.hasAttribute('data-day'),
|
||||
) as HTMLElement | undefined;
|
||||
if (colEl) {
|
||||
const day = colEl.getAttribute('data-day');
|
||||
if (day) {
|
||||
return day;
|
||||
}
|
||||
}
|
||||
|
||||
const days = this._daysToShow();
|
||||
return days.length ? days[0] : '';
|
||||
}
|
||||
|
||||
private _resolveDropTargets(ev: CdkDragRelease): {
|
||||
columnTarget: HTMLElement | null;
|
||||
scheduleEventTarget: HTMLElement | null;
|
||||
} {
|
||||
let columnTarget = this._lastDropCol;
|
||||
let scheduleEventTarget = this._lastDropScheduleEvent;
|
||||
|
||||
if (
|
||||
(!columnTarget || !scheduleEventTarget) &&
|
||||
ev.event.target instanceof HTMLElement
|
||||
) {
|
||||
const fallback = ev.event.target as HTMLElement;
|
||||
if (!columnTarget) {
|
||||
columnTarget = fallback.closest('.col') as HTMLElement | null;
|
||||
}
|
||||
if (!scheduleEventTarget) {
|
||||
scheduleEventTarget = fallback.closest('schedule-event') as HTMLElement | null;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleEventTarget = this._sanitizeScheduleEventTarget(scheduleEventTarget);
|
||||
|
||||
return { columnTarget, scheduleEventTarget };
|
||||
}
|
||||
|
||||
private _calculateTimeFromDrop(
|
||||
dropPoint: PointerPosition,
|
||||
targetDay: string,
|
||||
): number | null {
|
||||
const gridContainer = this._gridContainer();
|
||||
if (!gridContainer) {
|
||||
return null;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
|
||||
// Apply the same offset adjustment as in _createDragPreview to ensure
|
||||
// the drop time matches where the preview was visually positioned.
|
||||
const relativeY = dropPoint.y - gridRect.top;
|
||||
const totalRows = 24 * FH;
|
||||
const rowHeight = gridRect.height / totalRows;
|
||||
const rowSpan = this._calculateRowSpan(this._currentDragEvent());
|
||||
const rowOffset = Math.floor(rowSpan / 2);
|
||||
const row = Math.max(1, Math.round(relativeY / rowHeight) + 1 - rowOffset);
|
||||
|
||||
const offsetRows = row - 1;
|
||||
const offsetY = offsetRows * rowHeight;
|
||||
const adjustedY = gridRect.top + offsetY;
|
||||
|
||||
return calculateTimeFromYPosition(adjustedY, gridRect, targetDay);
|
||||
}
|
||||
|
||||
private _isOutsideGrid(dropPoint: PointerPosition): boolean {
|
||||
const gridContainer = this._gridContainer();
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
return (
|
||||
dropPoint.y < gridRect.top ||
|
||||
dropPoint.y > gridRect.bottom ||
|
||||
dropPoint.x < gridRect.left ||
|
||||
dropPoint.x > gridRect.right
|
||||
);
|
||||
}
|
||||
|
||||
private _scheduleTask(task: TaskCopy, scheduleTime: number): void {
|
||||
const hasExistingSchedule = !!task?.dueWithTime;
|
||||
const hasReminder = !!task?.reminderId;
|
||||
// Smart reminder logic: if task is brand new to scheduling, add a reminder at start.
|
||||
// If it already has a reminder, update it. Otherwise, leave reminders unchanged.
|
||||
const remindAt =
|
||||
!hasExistingSchedule && !hasReminder
|
||||
? remindOptionToMilliseconds(scheduleTime, TaskReminderOptionId.AtStart)
|
||||
: hasReminder
|
||||
? scheduleTime
|
||||
: undefined;
|
||||
|
||||
const payload = {
|
||||
task,
|
||||
dueWithTime: scheduleTime,
|
||||
...(typeof remindAt === 'number' ? { remindAt } : {}),
|
||||
isMoveToBacklog: false,
|
||||
};
|
||||
|
||||
this._store.dispatch(
|
||||
hasExistingSchedule
|
||||
? TaskSharedActions.reScheduleTaskWithTime(payload)
|
||||
: TaskSharedActions.scheduleTaskWithTime(payload),
|
||||
);
|
||||
|
||||
// Ensure task has a minimum duration so it's visible on the schedule.
|
||||
// Without this, zero-duration tasks would be invisible or hard to interact with.
|
||||
if (!task.timeEstimate || task.timeEstimate <= 0) {
|
||||
const fallbackDuration = Math.max(SCHEDULE_TASK_MIN_DURATION_IN_MS, 15 * 60 * 1000);
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.updateTask({
|
||||
task: {
|
||||
id: task.id,
|
||||
changes: { timeEstimate: fallbackDuration },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _extractDropPoint(
|
||||
event: MouseEvent | TouchEvent | PointerEvent,
|
||||
): PointerPosition | null {
|
||||
if ('clientX' in event) {
|
||||
return { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
if ('changedTouches' in event && event.changedTouches?.length) {
|
||||
const touch = event.changedTouches[0];
|
||||
return { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
if ('touches' in event && event.touches?.length) {
|
||||
const touch = event.touches[0];
|
||||
return { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _pluckTaskFromEvent(event: ScheduleEvent | null): TaskCopy | null {
|
||||
if (!event || !event.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case SVEType.Task:
|
||||
case SVEType.ScheduledTask:
|
||||
case SVEType.SplitTask:
|
||||
case SVEType.TaskPlannedForDay:
|
||||
case SVEType.SplitTaskPlannedForDay:
|
||||
return event.data as TaskCopy;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleColumnDrop({
|
||||
task,
|
||||
columnTarget,
|
||||
dropPoint,
|
||||
}: {
|
||||
task: TaskCopy;
|
||||
columnTarget: HTMLElement;
|
||||
dropPoint: PointerPosition | null;
|
||||
}): boolean {
|
||||
const isMoveToEndOfDay = columnTarget.classList.contains('end-of-day');
|
||||
const targetDay =
|
||||
columnTarget.getAttribute('data-day') ||
|
||||
(dropPoint ? this._getDayUnderPointer(dropPoint.x, dropPoint.y) : null);
|
||||
|
||||
if (!targetDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isShiftMode()) {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task,
|
||||
day: targetDay,
|
||||
isAddToTop: !isMoveToEndOfDay,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reuse cached timestamp if available to avoid recalculating from pointer position.
|
||||
const scheduleTime =
|
||||
this._lastCalculatedTimestamp ??
|
||||
(dropPoint ? this._calculateTimeFromDrop(dropPoint, targetDay) : null);
|
||||
|
||||
if (scheduleTime != null) {
|
||||
this._scheduleTask(task, scheduleTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task,
|
||||
day: targetDay,
|
||||
isAddToTop: !isMoveToEndOfDay,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleUnschedule(task: TaskCopy, sourceEvent: ScheduleEvent): void {
|
||||
// Check if task has scheduled time - unschedule it
|
||||
// This also removes from planner days (but not from today tag)
|
||||
if (task.dueWithTime) {
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.unscheduleTask({
|
||||
id: task.id,
|
||||
reminderId: task.reminderId,
|
||||
isLeaveInToday: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// apparently our today list is put together by tasks with a dueDay and by tasks inside TODAY_TAG.taskIds
|
||||
else if (task.dueDay) {
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.unscheduleTask({
|
||||
id: task.id,
|
||||
reminderId: task.reminderId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this._store
|
||||
.select(selectTodayTagTaskIds)
|
||||
.pipe(first())
|
||||
.subscribe((todayTagTaskIds) => {
|
||||
if (todayTagTaskIds.includes(task.id)) {
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.removeTasksFromTodayTag({
|
||||
taskIds: [task.id],
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export type DragPreviewContext =
|
||||
| { kind: 'time'; timestamp: number }
|
||||
| { kind: 'shift-column'; day: string; isEndOfDay: boolean }
|
||||
| { kind: 'shift-task'; taskId: string }
|
||||
| { kind: 'unschedule'; label: string }
|
||||
| null;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { FH } from '../schedule.const';
|
||||
|
||||
export interface PlaceholderInfo {
|
||||
style: string;
|
||||
time: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export type PlaceholderCalculationContext = {
|
||||
readonly event: MouseEvent;
|
||||
readonly gridElement: HTMLElement;
|
||||
readonly days: readonly string[];
|
||||
readonly isTouchPrimary: boolean;
|
||||
};
|
||||
|
||||
export const calculatePlaceholderForGridMove = (
|
||||
ctx: PlaceholderCalculationContext,
|
||||
): PlaceholderInfo | null => {
|
||||
const target = ctx.event.target;
|
||||
if (!(target instanceof HTMLElement) || !target.classList.contains('col')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridStyles = window.getComputedStyle(ctx.gridElement);
|
||||
const rowSizes = gridStyles.gridTemplateRows
|
||||
.split(' ')
|
||||
.map((size) => Number.parseFloat(size))
|
||||
.filter((size) => Number.isFinite(size));
|
||||
|
||||
if (!rowSizes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowIndex = 0;
|
||||
let yOffset = ctx.event.offsetY;
|
||||
|
||||
for (let i = 0; i < rowSizes.length; i++) {
|
||||
if (yOffset < rowSizes[i]) {
|
||||
rowIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
yOffset -= rowSizes[i];
|
||||
}
|
||||
|
||||
const targetColRowOffset = Number.parseInt(target.style.gridRowStart || '0', 10) - 2;
|
||||
const targetColColOffset = Number.parseInt(target.style.gridColumnStart || '0', 10);
|
||||
|
||||
if (Number.isNaN(targetColRowOffset) || Number.isNaN(targetColColOffset)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let targetRow = rowIndex;
|
||||
if (ctx.isTouchPrimary) {
|
||||
const mobileRowGroup = Math.floor(rowIndex / 3);
|
||||
targetRow = mobileRowGroup * 3;
|
||||
targetRow -= 1;
|
||||
}
|
||||
const row = targetRow + targetColRowOffset;
|
||||
const hours = Math.floor((row - 1) / FH);
|
||||
const minutes = Math.floor(((row - 1) % FH) * (60 / FH));
|
||||
const time = `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
const dateIndex = targetColColOffset - 2;
|
||||
const date = ctx.days[dateIndex] ?? '';
|
||||
|
||||
return {
|
||||
style: `grid-row: ${row} / span 6; grid-column: ${targetColColOffset} / span 1`,
|
||||
time,
|
||||
date,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<header
|
||||
class="week-header"
|
||||
[class.minimal]="isMinimalHeader()"
|
||||
[class.isInPanel]="isInPanel()"
|
||||
>
|
||||
<div class="days">
|
||||
<div class="filler"><!--for time --></div>
|
||||
|
|
@ -48,11 +48,7 @@
|
|||
style="grid-column: {{ $index + 2 }}; grid-row: 1 / span {{
|
||||
($index === 0 ? currentTimeRow() : endOfDayColRowStart()) - 1
|
||||
}}"
|
||||
>
|
||||
@if (isDragging() && isShiftNoScheduleMode()) {
|
||||
<div class="drop-label">{{ T.F.SCHEDULE.PLAN_START_DAY | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
></div>
|
||||
<div
|
||||
class="col end-of-day"
|
||||
[attr.data-day]="day"
|
||||
|
|
@ -61,11 +57,7 @@
|
|||
}} / span {{
|
||||
totalRows - ($index === 0 ? currentTimeRow() : endOfDayColRowStart())
|
||||
}}"
|
||||
>
|
||||
@if (isDragging() && isShiftNoScheduleMode()) {
|
||||
<div class="drop-label">{{ T.F.SCHEDULE.PLAN_END_DAY | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
></div>
|
||||
}
|
||||
|
||||
<!-- Work Start and End -->
|
||||
|
|
@ -153,8 +145,7 @@
|
|||
}
|
||||
|
||||
@if (
|
||||
((!isTaskDragActive() && !isAnyEventResizing()) || isCtrlPressed()) &&
|
||||
newTaskPlaceholder();
|
||||
(!isTaskDragActive() || isCtrlPressed()) && newTaskPlaceholder();
|
||||
as newTaskPlaceholder
|
||||
) {
|
||||
<create-task-placeholder
|
||||
|
|
@ -173,9 +164,13 @@
|
|||
class="custom-drag-preview"
|
||||
[event]="currentDragEvent()!"
|
||||
[style]="dragPreviewStyle()!"
|
||||
[class.isShiftInsertPreview]="dragPreviewContext()?.kind === 'shift-task'"
|
||||
[class.isScheduleForDay]="isShiftNoScheduleMode()"
|
||||
>
|
||||
@if (!isShiftNoScheduleMode() && dragPreviewTime()) {
|
||||
<div class="drag-time-badge">{{ dragPreviewTime() }}</div>
|
||||
@if (dragPreviewLabel()) {
|
||||
<div class="drag-preview-time-badge">
|
||||
{{ dragPreviewLabel() }}
|
||||
</div>
|
||||
}
|
||||
</schedule-event>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Show grabbing cursor during all drag operations
|
||||
&.is-dragging {
|
||||
cursor: grabbing !important;
|
||||
|
||||
.grid-container {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
}
|
||||
|
||||
// When dragging without shift key, disable pointer events on schedule-events
|
||||
// This prevents dropping tasks on other events (forces time-based scheduling)
|
||||
&.is-dragging:not(.isShiftKeyPressed) {
|
||||
|
|
@ -26,19 +35,17 @@
|
|||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
schedule-event {
|
||||
// Prevent CDK drag-drop from animating items out of the way in time mode.
|
||||
// Keep the custom drag preview exempt so its scale transform remains applied.
|
||||
schedule-event:not(.custom-drag-preview) {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
border: 2px solid red;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
--schedule-row-height: 11px;
|
||||
--schedule-row-height: 9px;
|
||||
--schedule-row-height-mobile: 7px;
|
||||
--schedule-fr: 12;
|
||||
--schedule-total-rows: calc(24 * var(--schedule-fr));
|
||||
|
|
@ -59,18 +66,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
// TODO fix that this currently only shows inside schedule-day-panel
|
||||
// Fix drag placeholder to stay in original position and make it subtle
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.3 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
position: static !important;
|
||||
border: 1px solid orange !important;
|
||||
|
||||
// Prevent any visual changes during drag
|
||||
&.cdk-drop-list-dragging {
|
||||
//transform: none !important;
|
||||
}
|
||||
opacity: 0.2 !important;
|
||||
//transform: none !important;
|
||||
//transition: none !important;
|
||||
//position: static !important;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
|
|
@ -87,11 +89,17 @@
|
|||
color: var(--text-color);
|
||||
background: var(--bg-lighter);
|
||||
|
||||
&.minimal {
|
||||
&.isInPanel {
|
||||
//border: 0;
|
||||
justify-content: center;
|
||||
border-top: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
* {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.filler {
|
||||
display: none;
|
||||
|
|
@ -281,12 +289,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
// prevent moving out the way
|
||||
schedule-event.cdk-drag-dragging + schedule-event {
|
||||
transform: translateY(0);
|
||||
// Prevent CDK from moving items out of the way when not in shift mode
|
||||
:host(:not(.isShiftKeyPressed)) {
|
||||
schedule-event.cdk-drag:not(.custom-drag-preview) {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
schedule-event.cdk-drag-dragging + schedule-event:not(.custom-drag-preview) {
|
||||
transform: none !important;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In shift mode, allow minimal movement but control it
|
||||
:host(.isShiftKeyPressed) {
|
||||
schedule-event.cdk-drag-dragging + schedule-event {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -372,29 +396,41 @@ schedule-event.cdk-drag-dragging + schedule-event {
|
|||
|
||||
// Custom drag preview
|
||||
.custom-drag-preview {
|
||||
--scale: 1.02;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
border: 3px solid var(--accent) !important;
|
||||
border-radius: 4px !important;
|
||||
border: 2px solid var(--c-primary) !important;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--whiteframe-shadow-8dp);
|
||||
transform-origin: center top;
|
||||
border-radius: 0;
|
||||
transform: scale(var(--scale)) !important;
|
||||
|
||||
// Override any schedule-event specific styles
|
||||
&.schedule-event,
|
||||
schedule-event {
|
||||
border: 3px solid var(--accent) !important;
|
||||
:host.is-dragging:not(.isShiftKeyPressed) & {
|
||||
transform-origin: center top;
|
||||
transform: scale(var(--scale)) !important;
|
||||
}
|
||||
|
||||
.drag-time-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background: var(--accent);
|
||||
color: var(--light-color);
|
||||
padding: 2px 6px;
|
||||
&.isScheduleForDay {
|
||||
border-radius: var(--card-border-radius);
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
//.drag-preview-time-badge {
|
||||
//font-size: 13px;
|
||||
//}
|
||||
}
|
||||
|
||||
// NOTE has global styles in _overwrite-material.scss
|
||||
.drag-preview-time-badge {
|
||||
top: 0 !important;
|
||||
right: -8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&.isShiftInsertPreview {
|
||||
transform: translate(-16px, -50%) scale(var(--scale)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import {
|
|||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
HostListener,
|
||||
LOCALE_ID,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
|
|
@ -15,21 +16,12 @@ import {
|
|||
} from '@angular/core';
|
||||
import { ScheduleEvent } from '../schedule.model';
|
||||
import { CdkDragMove, CdkDragRelease, CdkDragStart } from '@angular/cdk/drag-drop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import {
|
||||
FH,
|
||||
SCHEDULE_TASK_MIN_DURATION_IN_MS,
|
||||
SVEType,
|
||||
T_ID_PREFIX,
|
||||
} from '../schedule.const';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { calculateTimeFromYPosition } from '../schedule-utils';
|
||||
import { FH, SVEType } from '../schedule.const';
|
||||
import { isDraggableSE } from '../map-schedule-data/is-schedule-types-type';
|
||||
import { throttle } from '../../../util/decorators';
|
||||
import { CreateTaskPlaceholderComponent } from '../create-task-placeholder/create-task-placeholder.component';
|
||||
import { ScheduleEventComponent } from '../schedule-event/schedule-event.component';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { T } from '../../../t.const';
|
||||
import { IS_TOUCH_PRIMARY } from '../../../util/is-mouse-primary';
|
||||
|
|
@ -37,12 +29,12 @@ import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
|
|||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { TaskReminderOptionId } from '../../tasks/task.model';
|
||||
import { formatMonthDay } from '../../../util/format-month-day.util';
|
||||
import { ScheduleWeekDragService } from './schedule-week-drag.service';
|
||||
import { calculatePlaceholderForGridMove } from './schedule-week-placeholder.util';
|
||||
import { truncate } from '../../../util/truncate';
|
||||
|
||||
const D_HOURS = 24;
|
||||
const DRAG_CLONE_CLASS = 'drag-clone';
|
||||
const DRAG_OVER_CLASS = 'drag-over';
|
||||
|
||||
@Component({
|
||||
selector: 'schedule-week',
|
||||
|
|
@ -58,19 +50,23 @@ const DRAG_OVER_CLASS = 'drag-over';
|
|||
styleUrl: './schedule-week.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
providers: [ScheduleWeekDragService],
|
||||
host: {
|
||||
'[class.isCtrlKeyPressed]': 'isCtrlPressed()',
|
||||
'[class.isShiftKeyPressed]': 'isShiftNoScheduleMode()',
|
||||
'[class.is-dragging]': 'isDragging()',
|
||||
'[class.is-not-dragging]': '!isDragging()',
|
||||
'[class.is-resizing-event]': 'isAnyEventResizing()',
|
||||
'[class]': 'dragEventTypeClass()',
|
||||
},
|
||||
})
|
||||
export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private _store = inject(Store);
|
||||
private readonly _service = inject(ScheduleWeekDragService);
|
||||
private _dateTimeFormatService = inject(DateTimeFormatService);
|
||||
private _translateService = inject(TranslateService);
|
||||
private _defaultLocale = inject(LOCALE_ID);
|
||||
|
||||
isMinimalHeader = input<boolean>(false);
|
||||
isInPanel = input<boolean>(false);
|
||||
events = input<ScheduleEvent[] | null>([]);
|
||||
beyondBudget = input<ScheduleEvent[][] | null>([]);
|
||||
daysToShow = input<string[]>([]);
|
||||
|
|
@ -79,8 +75,9 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
isCtrlPressed = signal<boolean>(false);
|
||||
isTaskDragActive = input<boolean>(false);
|
||||
|
||||
// Track shift key during drag operations
|
||||
isShiftNoScheduleMode = signal(false);
|
||||
// Shift mode changes drag behavior: instead of scheduling at a time,
|
||||
// tasks are planned for the day or reordered relative to other tasks.
|
||||
readonly isShiftNoScheduleMode = this._service.isShiftMode;
|
||||
|
||||
FH = FH;
|
||||
IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
|
||||
|
|
@ -94,21 +91,16 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
);
|
||||
|
||||
times = computed(() => {
|
||||
const is12Hour = !this._dateTimeFormatService.is24HourFormat();
|
||||
return this.rowsByNr.map((_, index) => {
|
||||
if (is12Hour) {
|
||||
if (index === 0) {
|
||||
return '12:00 AM'; // Midnight
|
||||
} else if (index === 12) {
|
||||
return '12:00 PM'; // Noon
|
||||
} else if (index < 12) {
|
||||
return index.toString() + ':00 AM';
|
||||
} else {
|
||||
return (index - 12).toString() + ':00 PM';
|
||||
}
|
||||
} else {
|
||||
return index.toString() + ':00';
|
||||
}
|
||||
const uses24Hour = this._dateTimeFormatService.is24HourFormat();
|
||||
const formatter = new Intl.DateTimeFormat(this._dateTimeFormatService.currentLocale, {
|
||||
hour: uses24Hour ? '2-digit' : 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: !uses24Hour,
|
||||
});
|
||||
|
||||
return this.rowsByNr.map((_, hourIndex) => {
|
||||
const date = new Date(2000, 0, 1, hourIndex, 0, 0);
|
||||
return formatter.format(date);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -124,51 +116,96 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
date: string;
|
||||
} | null>(null);
|
||||
|
||||
isDragging = signal(false);
|
||||
isDraggingDelayed = signal(false);
|
||||
isCreateTaskActive = signal(false);
|
||||
prevDragOverEl = signal<HTMLElement | null>(null);
|
||||
dragCloneEl = signal<HTMLElement | null>(null);
|
||||
|
||||
// Drag preview properties for time indicator
|
||||
dragPreviewTime = signal<string | null>(null);
|
||||
dragPreviewPosition = signal({ x: 0, y: 0 });
|
||||
private _lastCalculatedTimestamp: number | null = null;
|
||||
|
||||
// Custom drag preview properties
|
||||
currentDragEvent = signal<ScheduleEvent | null>(null);
|
||||
dragPreviewGridPosition = signal<{ col: number; row: number } | null>(null);
|
||||
dragPreviewStyle = signal<string | null>(null);
|
||||
// Remember the last column and schedule-event under the pointer during a drag.
|
||||
// Touch interactions hide the underlying element with the drag preview, so we
|
||||
// cache these references while the pointer is moving and reuse them on release.
|
||||
private _lastDropCol: HTMLElement | null = null;
|
||||
private _lastDropScheduleEvent: HTMLElement | null = null;
|
||||
private _lastPointerPosition: { x: number; y: number } | null = null;
|
||||
|
||||
// Track if any event is being resized
|
||||
isDragging = this._service.isDragging;
|
||||
isAnyEventResizing = signal(false);
|
||||
isCreateTaskActive = signal(false);
|
||||
currentDragEvent = this._service.currentDragEvent;
|
||||
dragPreviewStyle = this._service.dragPreviewStyle;
|
||||
// Show shift key info tooltip
|
||||
showShiftKeyInfo = this._service.showShiftKeyInfo;
|
||||
|
||||
// Computed class for drag event type
|
||||
// Apply CSS class based on dragged event type to enable type-specific styling,
|
||||
// such as different colors or visual treatments for tasks vs split tasks.
|
||||
dragEventTypeClass = computed(() => {
|
||||
const currentEvent = this.currentDragEvent();
|
||||
return currentEvent ? currentEvent.type : '';
|
||||
});
|
||||
|
||||
// Show shift key info tooltip
|
||||
showShiftKeyInfo = signal(false);
|
||||
|
||||
readonly gridContainer = viewChild.required<ElementRef>('gridContainer');
|
||||
|
||||
// Drag preview properties for time indicator
|
||||
private readonly _dragPreviewContext = this._service.dragPreviewContext;
|
||||
readonly dragPreviewContext = this._service.dragPreviewContext;
|
||||
private readonly _dragOverTaskId = this._service.dragOverTaskId;
|
||||
|
||||
dragPreviewLabel = computed(() => {
|
||||
// Check if we're hovering over a task for reordering (shift mode)
|
||||
const dragOverTaskId = this._dragOverTaskId();
|
||||
const currentDraggedEvent = this.currentDragEvent();
|
||||
|
||||
if (dragOverTaskId && currentDraggedEvent) {
|
||||
// Find the hovered task from events to display its title
|
||||
const allEvents = this.safeEvents();
|
||||
const targetEvent = allEvents.find((ev) => {
|
||||
const task = ev.data as any;
|
||||
return task?.id === dragOverTaskId;
|
||||
});
|
||||
|
||||
if (targetEvent && targetEvent.data) {
|
||||
const targetTask = targetEvent.data as any;
|
||||
const taskTitle = truncate(targetTask.title || 'task', 20);
|
||||
const insertBeforeLabel = this._translateService.instant(
|
||||
T.F.SCHEDULE.INSERT_BEFORE,
|
||||
);
|
||||
return `⤷ ${insertBeforeLabel}: ${taskTitle}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = this._dragPreviewContext();
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
if (ctx.kind === 'time') {
|
||||
return this._dateTimeFormatService.formatTime(ctx.timestamp);
|
||||
}
|
||||
if (ctx.kind === 'shift-column') {
|
||||
const dateLabel = this._formatDateLabel(ctx.day);
|
||||
return (
|
||||
(ctx.isEndOfDay ? '⇩' : '⇧') +
|
||||
this._translateService.instant(
|
||||
ctx.isEndOfDay ? T.F.SCHEDULE.PLAN_END_DAY : T.F.SCHEDULE.PLAN_START_DAY,
|
||||
{ date: dateLabel },
|
||||
)
|
||||
);
|
||||
}
|
||||
if (ctx.kind === 'shift-task') {
|
||||
return null;
|
||||
}
|
||||
return ctx.label;
|
||||
});
|
||||
|
||||
private _currentAniTimeout: number | undefined;
|
||||
private _resizeObserver?: MutationObserver;
|
||||
|
||||
ngOnInit(): void {
|
||||
const workStartEnd = this.workStartEnd();
|
||||
// Position the "end of day" planning area based on work hours config,
|
||||
// or default to noon if not specified.
|
||||
this.endOfDayColRowStart.set(workStartEnd?.workStartRow || D_HOURS * 0.5 * FH);
|
||||
// Provide the live days signal so the drag service can map drops to columns.
|
||||
this._service.setDaysToShowAccessor(() => this.daysToShow() || []);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Use an accessor function to safely provide grid access without holding
|
||||
// a stale reference if the component re-renders or the grid is recreated.
|
||||
this._service.setGridContainer(() => {
|
||||
try {
|
||||
return this.gridContainer().nativeElement as HTMLElement;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
this._setupResizeObserver();
|
||||
}
|
||||
|
||||
|
|
@ -177,10 +214,16 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
// Clean up resize observer
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = undefined;
|
||||
}
|
||||
this.isAnyEventResizing.set(false);
|
||||
this._service.destroy();
|
||||
}
|
||||
|
||||
onGridClick(ev: MouseEvent): void {
|
||||
if (this.isAnyEventResizing()) {
|
||||
return;
|
||||
}
|
||||
if (ev.target instanceof HTMLElement) {
|
||||
if (ev.target.classList.contains('col')) {
|
||||
this.isCreateTaskActive.set(true);
|
||||
|
|
@ -188,474 +231,61 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
// Throttle to 30ms to reduce computational overhead during rapid mouse movements.
|
||||
@throttle(30)
|
||||
onMoveOverGrid(ev: MouseEvent): void {
|
||||
if (this.isDragging() || this.isDraggingDelayed()) {
|
||||
// Prevent showing the "create task" placeholder during or right after a drag
|
||||
// to avoid confusing visual feedback during the reset animation.
|
||||
if (this.isDragging()) {
|
||||
return;
|
||||
}
|
||||
if (this.isAnyEventResizing()) {
|
||||
this.newTaskPlaceholder.set(null);
|
||||
return;
|
||||
}
|
||||
if (this.isCreateTaskActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.target instanceof HTMLElement && ev.target.classList.contains('col')) {
|
||||
const gridContainer = this.gridContainer().nativeElement;
|
||||
const gridStyles = window.getComputedStyle(gridContainer);
|
||||
|
||||
const rowSizes = gridStyles.gridTemplateRows
|
||||
.split(' ')
|
||||
.map((size) => parseFloat(size));
|
||||
|
||||
let rowIndex = 0;
|
||||
let yOffset = ev.offsetY;
|
||||
|
||||
for (let i = 0; i < rowSizes.length; i++) {
|
||||
if (yOffset < rowSizes[i]) {
|
||||
rowIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
yOffset -= rowSizes[i];
|
||||
}
|
||||
|
||||
const targetColRowOffset = +ev.target.style.gridRowStart - 2;
|
||||
const targetColColOffset = +ev.target.style.gridColumnStart;
|
||||
|
||||
// for mobile, we use blocks of 15 minutes
|
||||
// eslint-disable-next-line no-mixed-operators
|
||||
const targetRow = IS_TOUCH_PRIMARY ? Math.floor(rowIndex / 3) * 3 - 1 : rowIndex;
|
||||
const row = targetRow + targetColRowOffset;
|
||||
const hours = Math.floor((row - 1) / FH);
|
||||
const minutes = Math.floor(((row - 1) % FH) * (60 / FH));
|
||||
const time = `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
this.newTaskPlaceholder.set({
|
||||
style: `grid-row: ${row} / span 6; grid-column: ${targetColColOffset} / span 1`,
|
||||
time,
|
||||
date: this.daysToShow()[targetColColOffset - 2],
|
||||
});
|
||||
} else {
|
||||
const gridRef = this.gridContainer();
|
||||
const gridElement = gridRef?.nativeElement as HTMLElement | undefined;
|
||||
if (!gridElement) {
|
||||
this.newTaskPlaceholder.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = calculatePlaceholderForGridMove({
|
||||
event: ev,
|
||||
gridElement,
|
||||
days: this.daysToShow() || [],
|
||||
isTouchPrimary: IS_TOUCH_PRIMARY,
|
||||
});
|
||||
|
||||
this.newTaskPlaceholder.set(placeholder);
|
||||
}
|
||||
|
||||
// Throttle drag updates to avoid excessive re-renders and DOM queries.
|
||||
@throttle(30)
|
||||
dragMoved(ev: CdkDragMove<ScheduleEvent>): void {
|
||||
if (!this.isDragging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.source.element.nativeElement.style.pointerEvents = 'none';
|
||||
const pointer = { x: ev.pointerPosition.x, y: ev.pointerPosition.y };
|
||||
const targetEl = this._updatePointerCaches(pointer);
|
||||
if (!targetEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update drag preview position for visual indicator (always follow pointer)
|
||||
this.dragPreviewPosition.set({ x: pointer.x, y: pointer.y });
|
||||
|
||||
const gridContainer = this.gridContainer().nativeElement;
|
||||
if (!gridContainer) {
|
||||
return;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
const targetDay = this.getDayUnderPointer(pointer.x, pointer.y);
|
||||
const isWithinGrid = this._isWithinGrid(pointer, gridRect);
|
||||
|
||||
if (this.isShiftNoScheduleMode()) {
|
||||
this._handleShiftDragMove(targetEl, pointer, gridRect, targetDay, isWithinGrid);
|
||||
} else {
|
||||
this._handleTimeDragMove(pointer, gridRect, targetDay, isWithinGrid);
|
||||
}
|
||||
}
|
||||
|
||||
private getDayUnderPointer(x: number, y: number): string {
|
||||
const elementsAtPoint = document.elementsFromPoint(x, y) as HTMLElement[];
|
||||
const colEl = elementsAtPoint.find(
|
||||
(el) => el?.classList?.contains('col') && el.hasAttribute('data-day'),
|
||||
) as HTMLElement | undefined;
|
||||
if (colEl) {
|
||||
const d = colEl.getAttribute('data-day');
|
||||
if (d) {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
return this.daysToShow()[0];
|
||||
}
|
||||
|
||||
private _createDragPreview(
|
||||
timestamp: number,
|
||||
targetDay: string,
|
||||
pointerY: number,
|
||||
gridRect: DOMRect,
|
||||
): void {
|
||||
// Set time preview
|
||||
const date = new Date(timestamp);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
this.dragPreviewTime.set(timeStr);
|
||||
|
||||
// Calculate grid position
|
||||
const relativeY = pointerY - gridRect.top;
|
||||
const totalRows = 24 * FH; // Total rows = 24 hours * FH rows per hour
|
||||
const rowHeight = gridRect.height / totalRows;
|
||||
const row = Math.round(relativeY / rowHeight) + 1;
|
||||
|
||||
const dayIndex = this.daysToShow().findIndex((day) => day === targetDay);
|
||||
const col = dayIndex + 2; // +2 because column 1 is time column
|
||||
|
||||
// Calculate correct row span based on event duration
|
||||
const rowSpan = this._calculateRowSpan(this.currentDragEvent());
|
||||
|
||||
// Create grid style for preview
|
||||
const gridStyle = [
|
||||
`grid-row: ${row} / span ${rowSpan}`,
|
||||
`grid-column: ${col} / span 1`,
|
||||
'z-index: 1000',
|
||||
'opacity: 0.8',
|
||||
'transform: scale(0.95)',
|
||||
'border: 3px solid #2196F3 !important',
|
||||
'box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important',
|
||||
'border-radius: 4px !important',
|
||||
].join('; ');
|
||||
|
||||
this.dragPreviewStyle.set(gridStyle);
|
||||
this._service.handleDragMoved(ev);
|
||||
}
|
||||
|
||||
dragStarted(ev: CdkDragStart<ScheduleEvent>): void {
|
||||
this.isDragging.set(true);
|
||||
this.isDraggingDelayed.set(true);
|
||||
|
||||
// Set the current dragging event for custom preview
|
||||
this.currentDragEvent.set(ev.source.data);
|
||||
this._lastDropCol = null;
|
||||
this._lastDropScheduleEvent = null;
|
||||
this._lastPointerPosition = null;
|
||||
|
||||
// Show shift key info on non-touch devices
|
||||
if (!IS_TOUCH_PRIMARY) {
|
||||
this.showShiftKeyInfo.set(true);
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.showShiftKeyInfo.set(false);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const cur = ev.source.element.nativeElement;
|
||||
|
||||
// Hide the original element being dragged
|
||||
cur.style.opacity = '0';
|
||||
|
||||
// Remove any existing clone
|
||||
const cloneEl = this.dragCloneEl();
|
||||
if (cloneEl) {
|
||||
cloneEl.remove();
|
||||
}
|
||||
this._service.handleDragStarted(ev);
|
||||
}
|
||||
|
||||
dragReleased(ev: CdkDragRelease): void {
|
||||
const prevEl = this.prevDragOverEl();
|
||||
if (prevEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
this.prevDragOverEl.set(null);
|
||||
}
|
||||
|
||||
const dropPoint = this._lastPointerPosition ?? this._extractDropPoint(ev.event);
|
||||
const cloneEl = this.dragCloneEl();
|
||||
if (cloneEl) {
|
||||
cloneEl.remove();
|
||||
}
|
||||
|
||||
this.isDragging.set(false);
|
||||
const nativeEl = ev.source.element.nativeElement;
|
||||
nativeEl.style.pointerEvents = '';
|
||||
nativeEl.style.opacity = '1';
|
||||
|
||||
this.dragPreviewTime.set(null);
|
||||
this.currentDragEvent.set(null);
|
||||
this.dragPreviewGridPosition.set(null);
|
||||
this.dragPreviewStyle.set(null);
|
||||
const savedTimestamp = this._lastCalculatedTimestamp;
|
||||
this._lastCalculatedTimestamp = null;
|
||||
|
||||
setTimeout(() => {
|
||||
nativeEl.style.opacity = '';
|
||||
nativeEl.style.pointerEvents = '';
|
||||
this.isDraggingDelayed.set(false);
|
||||
}, 100);
|
||||
|
||||
const { columnTarget, scheduleEventTarget } = this._resolveDropTargets(ev);
|
||||
const task = ev.source.data.data as any;
|
||||
|
||||
if (columnTarget && task) {
|
||||
const isMoveToEndOfDay = columnTarget.classList.contains('end-of-day');
|
||||
const targetDay =
|
||||
columnTarget.getAttribute('data-day') ||
|
||||
(dropPoint ? this.getDayUnderPointer(dropPoint.x, dropPoint.y) : null);
|
||||
|
||||
if (targetDay) {
|
||||
if (this.isShiftNoScheduleMode()) {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task,
|
||||
day: targetDay,
|
||||
isAddToTop: !isMoveToEndOfDay,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const scheduleTime =
|
||||
savedTimestamp ??
|
||||
(dropPoint ? this._calculateTimeFromDrop(dropPoint, targetDay) : null);
|
||||
|
||||
if (scheduleTime != null) {
|
||||
this._scheduleTask(task, scheduleTime);
|
||||
} else {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task,
|
||||
day: targetDay,
|
||||
isAddToTop: !isMoveToEndOfDay,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (scheduleEventTarget) {
|
||||
const sourceTaskId = nativeEl.id.replace(T_ID_PREFIX, '');
|
||||
const targetTaskId = scheduleEventTarget.id.replace(T_ID_PREFIX, '');
|
||||
|
||||
if (
|
||||
sourceTaskId &&
|
||||
sourceTaskId.length > 0 &&
|
||||
targetTaskId &&
|
||||
sourceTaskId !== targetTaskId
|
||||
) {
|
||||
this._store.dispatch(
|
||||
PlannerActions.moveBeforeTask({
|
||||
fromTask: ev.source.data.data,
|
||||
toTaskId: targetTaskId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (task && dropPoint && this._isOutsideGrid(dropPoint)) {
|
||||
this._store.dispatch(TaskSharedActions.planTasksForToday({ taskIds: [task.id] }));
|
||||
}
|
||||
|
||||
this._resetDragCaches();
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
ev.source.reset();
|
||||
}
|
||||
|
||||
private _resolveDropTargets(ev: CdkDragRelease): {
|
||||
columnTarget: HTMLElement | null;
|
||||
scheduleEventTarget: HTMLElement | null;
|
||||
} {
|
||||
let columnTarget = this._lastDropCol;
|
||||
let scheduleEventTarget = this._lastDropScheduleEvent;
|
||||
|
||||
if (
|
||||
(!columnTarget || !scheduleEventTarget) &&
|
||||
ev.event.target instanceof HTMLElement
|
||||
) {
|
||||
const fallback = ev.event.target as HTMLElement;
|
||||
if (!columnTarget) {
|
||||
columnTarget = fallback.closest('.col') as HTMLElement | null;
|
||||
}
|
||||
if (!scheduleEventTarget) {
|
||||
scheduleEventTarget = fallback.closest('schedule-event') as HTMLElement | null;
|
||||
}
|
||||
}
|
||||
|
||||
return { columnTarget, scheduleEventTarget };
|
||||
}
|
||||
|
||||
private _calculateTimeFromDrop(
|
||||
dropPoint: { x: number; y: number },
|
||||
targetDay: string,
|
||||
): number | null {
|
||||
const gridContainer = this.gridContainer().nativeElement;
|
||||
if (!gridContainer) {
|
||||
return null;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
return calculateTimeFromYPosition(dropPoint.y, gridRect, targetDay);
|
||||
}
|
||||
|
||||
private _isOutsideGrid(dropPoint: { x: number; y: number }): boolean {
|
||||
const gridContainer = this.gridContainer().nativeElement;
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
const gridRect = gridContainer.getBoundingClientRect();
|
||||
return (
|
||||
dropPoint.y < gridRect.top ||
|
||||
dropPoint.y > gridRect.bottom ||
|
||||
dropPoint.x < gridRect.left ||
|
||||
dropPoint.x > gridRect.right
|
||||
);
|
||||
}
|
||||
|
||||
private _scheduleTask(task: any, scheduleTime: number): void {
|
||||
const hasExistingSchedule = !!task?.dueWithTime;
|
||||
const hasReminder = !!task?.reminderId;
|
||||
const remindAt =
|
||||
!hasExistingSchedule && !hasReminder
|
||||
? remindOptionToMilliseconds(scheduleTime, TaskReminderOptionId.AtStart)
|
||||
: hasReminder
|
||||
? scheduleTime
|
||||
: undefined;
|
||||
|
||||
const payload = {
|
||||
task,
|
||||
dueWithTime: scheduleTime,
|
||||
...(typeof remindAt === 'number' ? { remindAt } : {}),
|
||||
isMoveToBacklog: false,
|
||||
};
|
||||
|
||||
this._store.dispatch(
|
||||
hasExistingSchedule
|
||||
? TaskSharedActions.reScheduleTaskWithTime(payload)
|
||||
: TaskSharedActions.scheduleTaskWithTime(payload),
|
||||
);
|
||||
|
||||
if (!task.timeEstimate || task.timeEstimate <= 0) {
|
||||
const fallbackDuration = Math.max(SCHEDULE_TASK_MIN_DURATION_IN_MS, 15 * 60 * 1000);
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.updateTask({
|
||||
task: {
|
||||
id: task.id,
|
||||
changes: { timeEstimate: fallbackDuration },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _resetDragCaches(): void {
|
||||
this._lastDropCol = null;
|
||||
this._lastDropScheduleEvent = null;
|
||||
this._lastPointerPosition = null;
|
||||
}
|
||||
|
||||
private _updatePointerCaches(pointer: { x: number; y: number }): HTMLElement | null {
|
||||
this._lastPointerPosition = pointer;
|
||||
const elementsAtPoint = document.elementsFromPoint(pointer.x, pointer.y);
|
||||
const interactiveElements = elementsAtPoint.filter(
|
||||
(el): el is HTMLElement =>
|
||||
el instanceof HTMLElement &&
|
||||
!el.classList.contains(DRAG_CLONE_CLASS) &&
|
||||
!el.classList.contains('custom-drag-preview'),
|
||||
);
|
||||
|
||||
if (interactiveElements.length) {
|
||||
this._lastDropCol =
|
||||
interactiveElements.find((el) => el.classList.contains('col')) || null;
|
||||
this._lastDropScheduleEvent =
|
||||
interactiveElements.find((el) => el.tagName.toLowerCase() === 'schedule-event') ||
|
||||
null;
|
||||
const targetEl = interactiveElements[0];
|
||||
return targetEl.classList.contains(DRAG_CLONE_CLASS) ? null : targetEl;
|
||||
}
|
||||
|
||||
const fallback = document.elementFromPoint(
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
) as HTMLElement | null;
|
||||
if (fallback && !fallback.classList.contains(DRAG_CLONE_CLASS)) {
|
||||
return fallback;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _handleShiftDragMove(
|
||||
targetEl: HTMLElement,
|
||||
pointer: { x: number; y: number },
|
||||
gridRect: DOMRect,
|
||||
targetDay: string,
|
||||
isWithinGrid: boolean,
|
||||
): void {
|
||||
this._lastCalculatedTimestamp = null;
|
||||
|
||||
if (isWithinGrid) {
|
||||
const timestamp = calculateTimeFromYPosition(pointer.y, gridRect, targetDay);
|
||||
if (timestamp) {
|
||||
this._createDragPreview(timestamp, targetDay, pointer.y, gridRect);
|
||||
this.dragPreviewTime.set(null);
|
||||
}
|
||||
} else {
|
||||
this.dragPreviewStyle.set(null);
|
||||
}
|
||||
|
||||
const prevEl = this.prevDragOverEl();
|
||||
if (prevEl && prevEl !== targetEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
}
|
||||
if (prevEl !== targetEl) {
|
||||
this.prevDragOverEl.set(targetEl);
|
||||
if (
|
||||
targetEl.classList.contains(SVEType.Task) ||
|
||||
targetEl.classList.contains(SVEType.SplitTask) ||
|
||||
targetEl.classList.contains(SVEType.SplitTaskPlannedForDay) ||
|
||||
targetEl.classList.contains(SVEType.TaskPlannedForDay)
|
||||
) {
|
||||
targetEl.classList.add(DRAG_OVER_CLASS);
|
||||
} else if (targetEl.classList.contains('col')) {
|
||||
targetEl.classList.add(DRAG_OVER_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTimeDragMove(
|
||||
pointer: { x: number; y: number },
|
||||
gridRect: DOMRect,
|
||||
targetDay: string,
|
||||
isWithinGrid: boolean,
|
||||
): void {
|
||||
const prevEl = this.prevDragOverEl();
|
||||
if (prevEl) {
|
||||
prevEl.classList.remove(DRAG_OVER_CLASS);
|
||||
this.prevDragOverEl.set(null);
|
||||
}
|
||||
|
||||
if (isWithinGrid) {
|
||||
const timestamp = calculateTimeFromYPosition(pointer.y, gridRect, targetDay);
|
||||
|
||||
this._lastCalculatedTimestamp = timestamp;
|
||||
|
||||
if (timestamp) {
|
||||
this._createDragPreview(timestamp, targetDay, pointer.y, gridRect);
|
||||
}
|
||||
} else {
|
||||
this.dragPreviewTime.set('UNSCHEDULE');
|
||||
this._lastCalculatedTimestamp = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _isWithinGrid(pointer: { x: number; y: number }, gridRect: DOMRect): boolean {
|
||||
return (
|
||||
pointer.y >= gridRect.top &&
|
||||
pointer.y <= gridRect.bottom &&
|
||||
pointer.x >= gridRect.left &&
|
||||
pointer.x <= gridRect.right
|
||||
);
|
||||
}
|
||||
|
||||
private _calculateRowSpan(event: ScheduleEvent | null): number {
|
||||
if (!event) {
|
||||
return 6;
|
||||
}
|
||||
const task = event.data as any;
|
||||
if (task?.timeEstimate) {
|
||||
const timeInHours = task.timeEstimate / (60 * 60 * 1000);
|
||||
return Math.max(Math.round(timeInHours * FH), 1);
|
||||
}
|
||||
return Math.max(Math.round(event.timeLeftInHours * FH), 1);
|
||||
this._service.handleDragReleased(ev);
|
||||
}
|
||||
|
||||
// Listen for modifier keys globally so users can switch drag modes mid-drag.
|
||||
// Document-level because key events must work even when focus is elsewhere.
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onDocumentKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Shift') {
|
||||
this.isShiftNoScheduleMode.set(true);
|
||||
this._service.setShiftMode(true);
|
||||
// Update preview immediately to reflect the mode change.
|
||||
this._service.refreshPreviewForCurrentPointer();
|
||||
}
|
||||
if (event.key === 'Control' || event.ctrlKey) {
|
||||
this.isCtrlPressed.set(true);
|
||||
|
|
@ -665,51 +295,65 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
@HostListener('document:keyup', ['$event'])
|
||||
onDocumentKeyUp(event: KeyboardEvent): void {
|
||||
if (event.key === 'Shift') {
|
||||
this.isShiftNoScheduleMode.set(false);
|
||||
this._service.setShiftMode(false);
|
||||
// Update preview immediately to reflect the mode change.
|
||||
this._service.refreshPreviewForCurrentPointer();
|
||||
}
|
||||
if (event.key === 'Control' || !event.ctrlKey) {
|
||||
this.isCtrlPressed.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private _extractDropPoint(
|
||||
event: MouseEvent | TouchEvent | PointerEvent,
|
||||
): { x: number; y: number } | null {
|
||||
// Mouse and pointer events expose client coordinates directly.
|
||||
if ('clientX' in event) {
|
||||
return { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
// Touchend exposes coordinates via changedTouches; touchmove via touches.
|
||||
if ('changedTouches' in event && event.changedTouches?.length) {
|
||||
const touch = event.changedTouches[0];
|
||||
return { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
if ('touches' in event && event.touches?.length) {
|
||||
const touch = event.touches[0];
|
||||
return { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _setupResizeObserver(): void {
|
||||
const gridContainer = this.gridContainer().nativeElement;
|
||||
if (!gridContainer) {
|
||||
const gridRef = this.gridContainer();
|
||||
const gridElement = gridRef?.nativeElement as HTMLElement | undefined;
|
||||
if (!gridElement) {
|
||||
this.isAnyEventResizing.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Observe for changes to is-resizing class on schedule-event elements
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
this._resizeObserver = new MutationObserver(() => {
|
||||
const resizingElements = gridContainer.querySelectorAll(
|
||||
'schedule-event.is-resizing',
|
||||
);
|
||||
const resizingElements = gridElement.querySelectorAll('schedule-event.is-resizing');
|
||||
this.isAnyEventResizing.set(resizingElements.length > 0);
|
||||
});
|
||||
|
||||
// Observe only the grid container instead of entire document for better performance
|
||||
this._resizeObserver.observe(gridContainer, {
|
||||
this._resizeObserver.observe(gridElement, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
private _formatDateLabel(dayStr: string): string {
|
||||
if (!dayStr) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(dayStr);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return dayStr;
|
||||
}
|
||||
const locale =
|
||||
this._dateTimeFormatService.currentLocale ||
|
||||
this._translateService.currentLang ||
|
||||
this._defaultLocale ||
|
||||
'en-US';
|
||||
return formatMonthDay(date, locale);
|
||||
}
|
||||
|
||||
// Public methods for external preview control (used by schedule-day-panel)
|
||||
showExternalPreview(event: ScheduleEvent, style: string, timestamp: number): void {
|
||||
this._service.showExternalPreview(event, style, timestamp);
|
||||
}
|
||||
|
||||
updateExternalPreview(style: string, timestamp: number): void {
|
||||
this._service.updateExternalPreview(style, timestamp);
|
||||
}
|
||||
|
||||
hideExternalPreview(): void {
|
||||
this._service.hideExternalPreview();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,103 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { computed, inject, Injectable, Signal } from '@angular/core';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { ScheduleEvent } from './schedule.model';
|
||||
import { interval } from 'rxjs';
|
||||
import {
|
||||
ScheduleCalendarMapEntry,
|
||||
ScheduleDay,
|
||||
ScheduleEvent,
|
||||
ScheduleLunchBreakCfg,
|
||||
ScheduleWorkStartEndCfg,
|
||||
} from './schedule.model';
|
||||
import { SVEType } from './schedule.const';
|
||||
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 { mapToScheduleDays } from './map-schedule-data/map-to-schedule-days';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectTimelineTasks } from '../work-context/store/work-context.selectors';
|
||||
import { selectPlannerDayMap } from '../planner/store/planner.selectors';
|
||||
import { selectTaskRepeatCfgsWithAndWithoutStartTime } from '../task-repeat-cfg/store/task-repeat-cfg.selectors';
|
||||
import { selectTimelineConfig } from '../config/store/global-config.reducer';
|
||||
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TaskService } from '../tasks/task.service';
|
||||
import { startWith } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ScheduleService {
|
||||
private _dateService = inject(DateService);
|
||||
private _store = inject(Store);
|
||||
private _calendarIntegrationService = inject(CalendarIntegrationService);
|
||||
private _taskService = inject(TaskService);
|
||||
|
||||
private _timelineTasks = toSignal(this._store.select(selectTimelineTasks));
|
||||
private _taskRepeatCfgs = toSignal(
|
||||
this._store.select(selectTaskRepeatCfgsWithAndWithoutStartTime),
|
||||
);
|
||||
private _timelineConfig = toSignal(this._store.select(selectTimelineConfig));
|
||||
private _plannerDayMap = toSignal(this._store.select(selectPlannerDayMap));
|
||||
private _icalEvents = toSignal(this._calendarIntegrationService.icalEvents$, {
|
||||
initialValue: [],
|
||||
});
|
||||
private _scheduleRefreshTick = toSignal(interval(2 * 60 * 1000).pipe(startWith(0)), {
|
||||
initialValue: 0,
|
||||
});
|
||||
|
||||
createScheduleDaysComputed(daysToShow: Signal<string[]>): Signal<ScheduleDay[]> {
|
||||
return computed(() => {
|
||||
this._scheduleRefreshTick();
|
||||
const timelineTasks = this._timelineTasks();
|
||||
const taskRepeatCfgs = this._taskRepeatCfgs();
|
||||
const timelineCfg = this._timelineConfig();
|
||||
const plannerDayMap = this._plannerDayMap();
|
||||
const icalEvents = this._icalEvents();
|
||||
const currentTaskId = this._taskService.currentTaskId() ?? null;
|
||||
|
||||
return this.buildScheduleDays({
|
||||
daysToShow: daysToShow(),
|
||||
timelineTasks,
|
||||
taskRepeatCfgs,
|
||||
icalEvents,
|
||||
plannerDayMap,
|
||||
timelineCfg,
|
||||
currentTaskId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildScheduleDays(params: BuildScheduleDaysParams): ScheduleDay[] {
|
||||
const {
|
||||
now = Date.now(),
|
||||
daysToShow,
|
||||
timelineTasks,
|
||||
taskRepeatCfgs,
|
||||
icalEvents,
|
||||
plannerDayMap,
|
||||
timelineCfg,
|
||||
currentTaskId = null,
|
||||
} = params;
|
||||
|
||||
if (!timelineTasks || !taskRepeatCfgs || !plannerDayMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapToScheduleDays(
|
||||
now,
|
||||
daysToShow,
|
||||
timelineTasks.unPlanned,
|
||||
timelineTasks.planned,
|
||||
taskRepeatCfgs.withStartTime,
|
||||
taskRepeatCfgs.withoutStartTime,
|
||||
icalEvents ?? [],
|
||||
currentTaskId,
|
||||
plannerDayMap,
|
||||
timelineCfg?.isWorkStartEndEnabled ? createWorkStartEndCfg(timelineCfg) : undefined,
|
||||
timelineCfg?.isLunchBreakEnabled ? createLunchBreakCfg(timelineCfg) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
getDaysToShow(nrOfDaysToShow: number): string[] {
|
||||
const today = new Date().getTime();
|
||||
|
|
@ -172,3 +262,34 @@ export class ScheduleService {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createWorkStartEndCfg = (timelineCfg: ScheduleConfig): ScheduleWorkStartEndCfg => ({
|
||||
startTime: timelineCfg.workStart,
|
||||
endTime: timelineCfg.workEnd,
|
||||
});
|
||||
|
||||
const createLunchBreakCfg = (timelineCfg: ScheduleConfig): ScheduleLunchBreakCfg => ({
|
||||
startTime: timelineCfg.lunchBreakStart,
|
||||
endTime: timelineCfg.lunchBreakEnd,
|
||||
});
|
||||
|
||||
type TimelineTasks = {
|
||||
planned: TaskWithDueTime[];
|
||||
unPlanned: TaskWithSubTasks[];
|
||||
};
|
||||
|
||||
type TaskRepeatCfgBuckets = {
|
||||
withStartTime: TaskRepeatCfg[];
|
||||
withoutStartTime: TaskRepeatCfg[];
|
||||
};
|
||||
|
||||
export interface BuildScheduleDaysParams {
|
||||
now?: number;
|
||||
daysToShow: string[];
|
||||
timelineTasks: TimelineTasks | undefined | null;
|
||||
taskRepeatCfgs: TaskRepeatCfgBuckets | undefined | null;
|
||||
icalEvents: ScheduleCalendarMapEntry[] | undefined | null;
|
||||
plannerDayMap: PlannerDayMap | undefined | null;
|
||||
timelineCfg?: ScheduleConfig | null;
|
||||
currentTaskId?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,27 @@
|
|||
/* eslint-disable */
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { fromEvent } from 'rxjs';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
import { selectTimelineTasks } from '../../work-context/store/work-context.selectors';
|
||||
import { selectPlannerDayMap } from '../../planner/store/planner.selectors';
|
||||
import { debounceTime, map, startWith } from 'rxjs/operators';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { LayoutService } from '../../../core-ui/layout/layout.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { CalendarIntegrationService } from '../../calendar-integration/calendar-integration.service';
|
||||
import { LS } from '../../../core/persistence/storage-keys.const';
|
||||
import { DialogTimelineSetupComponent } from '../dialog-timeline-setup/dialog-timeline-setup.component';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import {
|
||||
selectTimelineConfig,
|
||||
selectTimelineWorkStartEndHours,
|
||||
selectMiscConfig,
|
||||
selectTimelineWorkStartEndHours,
|
||||
} from '../../config/store/global-config.reducer';
|
||||
import { FH } from '../schedule.const';
|
||||
import { mapToScheduleDays } from '../map-schedule-data/map-to-schedule-days';
|
||||
import { mapScheduleDaysToScheduleEvents } from '../map-schedule-data/map-schedule-days-to-schedule-events';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { selectTaskRepeatCfgsWithAndWithoutStartTime } from '../../task-repeat-cfg/store/task-repeat-cfg.selectors';
|
||||
import { ScheduleWeekComponent } from '../schedule-week/schedule-week.component';
|
||||
import { ScheduleMonthComponent } from '../schedule-month/schedule-month.component';
|
||||
import { ScheduleService } from '../schedule.service';
|
||||
|
|
@ -53,10 +43,8 @@ export class ScheduleComponent implements AfterViewInit {
|
|||
layoutService = inject(LayoutService);
|
||||
scheduleService = inject(ScheduleService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _calendarIntegrationService = inject(CalendarIntegrationService);
|
||||
private _store = inject(Store);
|
||||
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
|
||||
private _route = inject(ActivatedRoute);
|
||||
|
||||
private _currentTimeViewMode = computed(() => this.layoutService.selectedTimeView());
|
||||
isMonthView = computed(() => this._currentTimeViewMode() === 'month');
|
||||
|
|
@ -125,55 +113,9 @@ export class ScheduleComponent implements AfterViewInit {
|
|||
return miscConfig?.firstDayOfWeek ?? 1; // Default to Monday
|
||||
});
|
||||
|
||||
private _timelineTasks = toSignal(this._store.pipe(select(selectTimelineTasks)));
|
||||
private _taskRepeatCfgs = toSignal(
|
||||
this._store.pipe(select(selectTaskRepeatCfgsWithAndWithoutStartTime)),
|
||||
);
|
||||
private _timelineConfig = toSignal(this._store.pipe(select(selectTimelineConfig)));
|
||||
private _miscConfig = toSignal(this._store.pipe(select(selectMiscConfig)));
|
||||
private _icalEvents = toSignal(this._calendarIntegrationService.icalEvents$, {
|
||||
initialValue: [],
|
||||
});
|
||||
private _plannerDayMap = toSignal(this._store.pipe(select(selectPlannerDayMap)));
|
||||
private _currentTaskId = toSignal(this.taskService.currentTaskId$);
|
||||
|
||||
scheduleDays = computed(() => {
|
||||
const timelineTasks = this._timelineTasks();
|
||||
const taskRepeatCfgs = this._taskRepeatCfgs();
|
||||
const timelineCfg = this._timelineConfig();
|
||||
const icalEvents = this._icalEvents();
|
||||
const plannerDayMap = this._plannerDayMap();
|
||||
const currentId = this._currentTaskId();
|
||||
const daysToShow = this.daysToShow();
|
||||
|
||||
if (!timelineTasks || !taskRepeatCfgs || !plannerDayMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapToScheduleDays(
|
||||
Date.now(),
|
||||
daysToShow,
|
||||
timelineTasks.unPlanned,
|
||||
timelineTasks.planned,
|
||||
taskRepeatCfgs.withStartTime,
|
||||
taskRepeatCfgs.withoutStartTime,
|
||||
icalEvents,
|
||||
currentId || null,
|
||||
plannerDayMap,
|
||||
timelineCfg?.isWorkStartEndEnabled
|
||||
? {
|
||||
startTime: timelineCfg.workStart,
|
||||
endTime: timelineCfg.workEnd,
|
||||
}
|
||||
: undefined,
|
||||
timelineCfg?.isLunchBreakEnabled
|
||||
? {
|
||||
startTime: timelineCfg.lunchBreakStart,
|
||||
endTime: timelineCfg.lunchBreakEnd,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
scheduleDays = this.scheduleService.createScheduleDaysComputed(this.daysToShow);
|
||||
|
||||
private _eventsAndBeyondBudget = computed(() => {
|
||||
const days = this.scheduleDays();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { initialTagState, tagReducer } from './tag.reducer';
|
|||
import { Tag } from '../tag.model';
|
||||
import { addTag } from './tag.actions';
|
||||
import { TODAY_TAG } from '../tag.const';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { DEFAULT_TASK } from '../../tasks/task.model';
|
||||
import * as getDbDateStrUtil from '../../../util/get-db-date-str';
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
|
|
@ -35,4 +38,156 @@ describe('TagReducer', () => {
|
|||
expect(state.entities['2']).toEqual(newTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planTaskForDay', () => {
|
||||
it('should add new task to today tag when planning for today', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task1',
|
||||
'task2',
|
||||
'task3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add task to top of today tag when isAddToTop is true', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: true,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task3',
|
||||
'task1',
|
||||
'task2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle reordering existing task within today tag', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false, // move to end
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual([
|
||||
'task2',
|
||||
'task3',
|
||||
'task1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove task from today tag when planning for future day', () => {
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2', 'task3'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task2', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect((result.entities[TODAY_TAG.id] as Tag).taskIds).toEqual(['task1', 'task3']);
|
||||
});
|
||||
|
||||
it('should not modify state when planning for future day with task not in today', () => {
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task3', projectId: 'test', subTaskIds: [] },
|
||||
day: '2024-01-16', // future day
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
expect(result).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should not duplicate task when already in today list', () => {
|
||||
const todayStr = getDbDateStrUtil.getDbDateStr();
|
||||
const initialState = {
|
||||
...initialTagState,
|
||||
entities: {
|
||||
...initialTagState.entities,
|
||||
[TODAY_TAG.id]: {
|
||||
...TODAY_TAG,
|
||||
taskIds: ['task1', 'task2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = PlannerActions.planTaskForDay({
|
||||
task: { ...DEFAULT_TASK, id: 'task1', projectId: 'test', subTaskIds: [] },
|
||||
day: todayStr,
|
||||
isAddToTop: false,
|
||||
});
|
||||
|
||||
const result = tagReducer(initialState, action);
|
||||
const taskIds = (result.entities[TODAY_TAG.id] as Tag).taskIds;
|
||||
expect(taskIds).toEqual(['task2', 'task1']);
|
||||
expect(taskIds.filter((id) => id === 'task1').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,21 +121,24 @@ export const tagReducer = createReducer<TagState>(
|
|||
const todayStr = getDbDateStr();
|
||||
const todayTag = state.entities[TODAY_TAG.id] as Tag;
|
||||
|
||||
if (day === todayStr && !todayTag.taskIds.includes(task.id)) {
|
||||
if (day === todayStr) {
|
||||
// Always remove first, then add in correct position (handles reordering)
|
||||
const taskIdsWithoutCurrent = todayTag.taskIds.filter((id) => id !== task.id);
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
changes: {
|
||||
taskIds: unique(
|
||||
isAddToTop
|
||||
? [task.id, ...todayTag.taskIds]
|
||||
: [...todayTag.taskIds.filter((tid) => tid !== task.id), task.id],
|
||||
? [task.id, ...taskIdsWithoutCurrent]
|
||||
: [...taskIdsWithoutCurrent, task.id],
|
||||
),
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
} else if (day !== todayStr && todayTag.taskIds.includes(task.id)) {
|
||||
} else if (todayTag.taskIds.includes(task.id)) {
|
||||
// Moving away from today, remove from today's list
|
||||
return tagAdapter.updateOne(
|
||||
{
|
||||
id: todayTag.id,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@
|
|||
cdkDrag
|
||||
[cdkDragDisabled]="listModelId() === 'LATER_TODAY'"
|
||||
[cdkDragData]="task"
|
||||
(cdkDragStarted)="onDragStarted(task)"
|
||||
(cdkDragEnded)="onDragEnded()"
|
||||
>
|
||||
</task>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { TaskComponent } from '../task/task.component';
|
|||
import { AsyncPipe } from '@angular/common';
|
||||
import { TaskViewCustomizerService } from '../../task-view-customizer/task-view-customizer.service';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
import { ScheduleExternalDragService } from '../../schedule/schedule-week/schedule-external-drag.service';
|
||||
|
||||
export type TaskListId = 'PARENT' | 'SUB';
|
||||
export type ListModelId = DropListModelSource | string;
|
||||
|
|
@ -70,6 +71,7 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
private _store = inject(Store);
|
||||
private _issueService = inject(IssueService);
|
||||
private _taskViewCustomizerService = inject(TaskViewCustomizerService);
|
||||
private _scheduleExternalDragService = inject(ScheduleExternalDragService);
|
||||
dropListService = inject(DropListService);
|
||||
|
||||
tasks = input<TaskWithSubTasks[]>([]);
|
||||
|
|
@ -120,12 +122,21 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
|
||||
ngOnDestroy(): void {
|
||||
this.dropListService.unregisterDropList(this.dropList()!);
|
||||
this._scheduleExternalDragService.setActiveTask(null);
|
||||
}
|
||||
|
||||
trackByFn(i: number, task: Task): string {
|
||||
return task.id;
|
||||
}
|
||||
|
||||
onDragStarted(task: TaskWithSubTasks): void {
|
||||
this._scheduleExternalDragService.setActiveTask(task);
|
||||
}
|
||||
|
||||
onDragEnded(): void {
|
||||
this._scheduleExternalDragService.setActiveTask(null);
|
||||
}
|
||||
|
||||
enterPredicate(drag: CdkDrag, drop: CdkDropList): boolean {
|
||||
// TODO this gets called very often for nested lists. Maybe there are possibilities to optimize
|
||||
const task = drag.data;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,11 @@ const handleScheduleTaskWithTime = (
|
|||
]);
|
||||
};
|
||||
|
||||
const handleUnScheduleTask = (state: RootState, taskId: string): RootState => {
|
||||
const handleUnScheduleTask = (
|
||||
state: RootState,
|
||||
taskId: string,
|
||||
isLeaveInToday = false,
|
||||
): RootState => {
|
||||
// First, update the task entity to clear scheduling data
|
||||
const updatedState = {
|
||||
...state,
|
||||
|
|
@ -96,8 +100,7 @@ const handleUnScheduleTask = (state: RootState, taskId: string): RootState => {
|
|||
|
||||
// Then, handle today tag updates
|
||||
const todayTag = getTag(updatedState, TODAY_TAG.id);
|
||||
|
||||
if (!todayTag.taskIds.includes(taskId)) {
|
||||
if (!todayTag.taskIds.includes(taskId) || isLeaveInToday) {
|
||||
return updatedState;
|
||||
}
|
||||
|
||||
|
|
@ -245,8 +248,10 @@ const createActionHandlers = (state: RootState, action: Action): ActionHandlerMa
|
|||
return handleScheduleTaskWithTime(state, task, dueWithTime);
|
||||
},
|
||||
[TaskSharedActions.unscheduleTask.type]: () => {
|
||||
const { id } = action as ReturnType<typeof TaskSharedActions.unscheduleTask>;
|
||||
return handleUnScheduleTask(state, id);
|
||||
const { id, isLeaveInToday } = action as ReturnType<
|
||||
typeof TaskSharedActions.unscheduleTask
|
||||
>;
|
||||
return handleUnScheduleTask(state, id, isLeaveInToday);
|
||||
},
|
||||
[TaskSharedActions.dismissReminderOnly.type]: () => {
|
||||
const { id } = action as ReturnType<typeof TaskSharedActions.dismissReminderOnly>;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export const TaskSharedActions = createActionGroup({
|
|||
id: string;
|
||||
reminderId?: string;
|
||||
isSkipToast?: boolean;
|
||||
isLeaveInToday?: boolean;
|
||||
}>(),
|
||||
|
||||
dismissReminderOnly: props<{
|
||||
|
|
|
|||
|
|
@ -952,6 +952,7 @@ const T = {
|
|||
MONTH: 'F.SCHEDULE.MONTH',
|
||||
NO_TASKS: 'F.SCHEDULE.NO_TASKS',
|
||||
NOW: 'F.SCHEDULE.NOW',
|
||||
INSERT_BEFORE: 'F.SCHEDULE.INSERT_BEFORE',
|
||||
PLAN_END_DAY: 'F.SCHEDULE.PLAN_END_DAY',
|
||||
PLAN_START_DAY: 'F.SCHEDULE.PLAN_START_DAY',
|
||||
SHIFT_KEY_INFO: 'F.SCHEDULE.SHIFT_KEY_INFO',
|
||||
|
|
|
|||
19
src/app/ui/help-box/help-box.component.html
Normal file
19
src/app/ui/help-box/help-box.component.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@if (isVisible()) {
|
||||
<div
|
||||
[@expandFade]
|
||||
class="help-box"
|
||||
>
|
||||
<button
|
||||
(click)="onClose()"
|
||||
aria-label="Close help box"
|
||||
class="close-btn"
|
||||
mat-icon-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<div class="help-box-content">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
33
src/app/ui/help-box/help-box.component.scss
Normal file
33
src/app/ui/help-box/help-box.component.scss
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
:host {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
background: var(--bg);
|
||||
z-index: 200;
|
||||
margin: 8px;
|
||||
border: 2px solid var(--separator-color);
|
||||
border-radius: var(--card-border-radius);
|
||||
|
||||
.help-box {
|
||||
position: relative;
|
||||
padding: var(--s);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-bg-info);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
//.help-box-content {}
|
||||
|
||||
:host-context([dir='rtl']) {
|
||||
.close-btn {
|
||||
right: unset;
|
||||
left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/app/ui/help-box/help-box.component.spec.ts
Normal file
33
src/app/ui/help-box/help-box.component.spec.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { HelpBoxComponent } from './help-box.component';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
|
||||
describe('HelpBoxComponent', () => {
|
||||
let component: HelpBoxComponent;
|
||||
let fixture: ComponentFixture<HelpBoxComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HelpBoxComponent],
|
||||
providers: [provideAnimations()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HelpBoxComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.lsKey = 'TEST_HELP_BOX';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show the help box by default', () => {
|
||||
expect(component.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide the help box when close is clicked', () => {
|
||||
component.onClose();
|
||||
expect(component.isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
31
src/app/ui/help-box/help-box.component.ts
Normal file
31
src/app/ui/help-box/help-box.component.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, Signal, signal } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { expandFadeAnimation } from '../animations/expand.ani';
|
||||
import { lsGetBoolean, lsSetItem } from '../../util/ls-util';
|
||||
|
||||
@Component({
|
||||
selector: 'help-box',
|
||||
templateUrl: './help-box.component.html',
|
||||
styleUrls: ['./help-box.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandFadeAnimation],
|
||||
imports: [MatIcon, MatIconButton],
|
||||
})
|
||||
export class HelpBoxComponent {
|
||||
@Input({ required: true }) lsKey!: string;
|
||||
|
||||
isVisible: Signal<boolean> = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check localStorage to determine if the help box should be shown
|
||||
const isDismissed = lsGetBoolean(this.lsKey, false);
|
||||
(this.isVisible as any).set(!isDismissed);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
// Set the localStorage key to true to indicate the box was dismissed
|
||||
lsSetItem(this.lsKey, true);
|
||||
(this.isVisible as any).set(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -939,8 +939,9 @@
|
|||
"MONTH": "Month",
|
||||
"NO_TASKS": "Currently there are no tasks. Please add some tasks via the + Button in the top bar.",
|
||||
"NOW": "Now",
|
||||
"PLAN_END_DAY": "Plan at end of day",
|
||||
"PLAN_START_DAY": "Plan at start of day",
|
||||
"INSERT_BEFORE": "Before",
|
||||
"PLAN_END_DAY": "End of {{date}}",
|
||||
"PLAN_START_DAY": "Start of {{date}}",
|
||||
"SHIFT_KEY_INFO": "Hold Shift to toggle day planning mode",
|
||||
"START": "Work Start",
|
||||
"TASK_PROJECTION_INFO": "Future projection of a scheduled repeatable task",
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ mat-icon.mat-icon[svgicon] {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
// Ensure icon flex behaviour stays consistent even before Angular Material
|
||||
// injects the MatButton styles (which set min-height: fit-content lazily).
|
||||
body .mat-mdc-button-base .mat-icon {
|
||||
//flex-shrink: 1 !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
// AUTOCOMPLETE
|
||||
// -----------------
|
||||
.mdc-list-item__primary-text {
|
||||
|
|
@ -186,6 +193,7 @@ mat-icon.mat-icon[svgicon] {
|
|||
box-shadow: var(--whiteframe-shadow-8dp) !important;
|
||||
cursor: grabbing !important;
|
||||
overflow: hidden;
|
||||
pointer-events: none !important;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
|
|
@ -195,6 +203,28 @@ mat-icon.mat-icon[svgicon] {
|
|||
background: var(--bg-lightest) !important;
|
||||
}
|
||||
|
||||
// Schedule-style preview should always use accent border while dragging
|
||||
.cdk-drag-preview.as-schedule-event-preview {
|
||||
}
|
||||
|
||||
.drag-preview-time-badge {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--c-primary);
|
||||
border-radius: var(--card-border-radius);
|
||||
background: var(--bg-lighter);
|
||||
background: inherit;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
padding: 2px 6px;
|
||||
box-shadow: var(--whiteframe-shadow-3dp);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
cursor: grabbing;
|
||||
opacity: 0.1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue