diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html
index e972710e53..7292856323 100644
--- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html
+++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.html
@@ -1,12 +1,7 @@
diff --git a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
index 12676e099a..e861ca81b0 100644
--- a/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
+++ b/src/app/features/schedule/schedule-day-panel/schedule-day-panel.component.ts
@@ -29,34 +29,38 @@ import { mapToScheduleDays } from '../map-schedule-data/map-to-schedule-days';
import { mapScheduleDaysToScheduleEvents } from '../map-schedule-data/map-schedule-days-to-schedule-events';
import { FH } from '../schedule.const';
import { calculateTimeFromYPosition } from '../schedule-utils';
-import { CdkDrag, CdkDragDrop, CdkDragEnter, CdkDropList } from '@angular/cdk/drag-drop';
-import { DropListService } from '../../../core-ui/drop-list/drop-list.service';
+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';
const DEFAULT_MIN_DURATION = 15 * 60 * 1000;
@Component({
selector: 'schedule-day-panel',
standalone: true,
- imports: [ScheduleWeekComponent, CdkDropList],
+ 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('dropList') dropList!: CdkDropList;
+ @ViewChild('dropZone', { read: ElementRef }) dropZoneRef!: ElementRef;
private _store = inject(Store);
private _dateService = inject(DateService);
private _calendarIntegrationService = inject(CalendarIntegrationService);
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
- private _dropListService = inject(DropListService);
+ private _dragDropRegistry = inject(DragDropRegistry);
+ private _externalDragService = inject(ScheduleExternalDragService);
private _cdr = inject(ChangeDetectorRef);
private _ngZone = inject(NgZone);
+ private _pointerUpSubscription: Subscription | null = null;
+ private _activeExternalTask: TaskWithSubTasks | null = null;
// Drag preview properties
dragPreviewTime = signal(null);
@@ -164,8 +168,10 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
}
ngAfterViewInit(): void {
- // Register this component as a drop target
- this._dropListService.registerDropList(this.dropList);
+ // 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
setTimeout(() => {
@@ -174,62 +180,49 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
}
ngOnDestroy(): void {
- // Unregister the drop list when component is destroyed
- if (this.dropList) {
- this._dropListService.unregisterDropList(this.dropList);
+ if (this._pointerUpSubscription) {
+ this._pointerUpSubscription.unsubscribe();
+ this._pointerUpSubscription = null;
}
-
// Ensure preview styling is cleaned up
this._applySchedulePreviewStyling(false);
- }
-
- onDragEnter(event: CdkDragEnter<{ day: string }>): void {
- this._startScheduleMode();
- }
-
- onDragExit(): void {
- this._stopScheduleMode();
+ this._activeExternalTask = null;
}
onDragMove(event: MouseEvent | TouchEvent): void {
- // If dragging continues and we re-enter without cdkDropListEntered firing,
- // auto-enable schedule mode when a CDK drag preview exists and it's not a schedule-event drag
- const dragPreview = document.querySelector('.cdk-drag-preview') as HTMLElement | null;
- if (dragPreview) {
- const isScheduleEventDrag = !!dragPreview.querySelector('schedule-event');
- if (!this.isDragging() && !isScheduleEventDrag) {
- this._startScheduleMode();
+ const activeTask = this._externalDragService.activeTask();
+ if (!activeTask) {
+ if (this.isDragging()) {
+ this._stopScheduleMode();
}
- }
- if (!this.isDragging()) return;
-
- const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
-
- // Update position for the preview badge (still follow cursor for visibility)
-
- // Get the actual drag preview position to match what dropPoint will be
- let previewY = clientY;
-
- if (dragPreview) {
- const rect = dragPreview.getBoundingClientRect();
- // Use the top of the drag preview, which should match event.dropPoint
- previewY = rect.top;
+ return;
}
- // Calculate time based on the drag preview position (matching dropPoint logic)
+ this._activeExternalTask = activeTask;
+ if (!this.isDragging()) {
+ this._startScheduleMode();
+ }
+
+ // Always use the top of the task element for time calculation
+ const dragPreview = document.querySelector('.cdk-drag-preview') as HTMLElement | null;
+ if (!dragPreview) {
+ return;
+ }
+
+ const rect = dragPreview.getBoundingClientRect();
+ // Use the top of the drag preview for consistent time calculation
+ const previewY = rect.top;
+
const timestamp = this._calculateTimeFromYPosition(previewY);
this.lastCalculatedTimestamp = timestamp;
if (timestamp) {
- const date = new Date(timestamp);
- const hours = date.getHours();
- const minutes = date.getMinutes();
- // TODO internationalization
- const timeStr = `${hours.toString().padStart(2, '0')}:${minutes
- .toString()
- .padStart(2, '0')}`;
+ const timeStr = this._formatPreviewTime(timestamp);
this.dragPreviewTime.set(timeStr);
this._updatePreviewTimeBadge(timeStr);
+ } else {
+ this.dragPreviewTime.set(null);
+ this._updatePreviewTimeBadge('');
}
}
@@ -246,70 +239,6 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
return calculateTimeFromYPosition(clientY, gridRect, targetDay);
}
- onTaskDrop(event: CdkDragDrop<{ day: string }, any, TaskWithSubTasks>): void {
- // Check if this is a schedule-event being dropped (internal drag)
- const dragElement = event.item.element.nativeElement;
- if (dragElement && dragElement.tagName.toLowerCase() === 'schedule-event') {
- // This is a schedule-event drop, let the schedule-week component handle it
- this.isDragging.set(false);
- this.dragPreviewTime.set(null);
- this.lastCalculatedTimestamp = null;
- return;
- }
-
- // Calculate drop time BEFORE clearing drag state (while preview element still exists)
- const dropTime = this._calculateDropTime();
-
- // Clear drag state after calculation
- this._stopScheduleMode();
-
- // Don't handle internal moves within the schedule
- if (event.previousContainer === event.container) {
- return;
- }
-
- const task = event.item.data;
- const targetDay = this.daysToShow()[0];
-
- if (task && targetDay) {
- if (dropTime !== null) {
- // No shift: Schedule the task at the specific time (new default behavior)
- this._store.dispatch(
- TaskSharedActions.scheduleTaskWithTime({
- task: task,
- dueWithTime: dropTime,
- isMoveToBacklog: false,
- }),
- );
- // Auto-assign a default duration if none is set
- if (!task.timeEstimate || task.timeEstimate <= 0) {
- this._store.dispatch(
- TaskSharedActions.updateTask({
- task: { id: task.id, changes: { timeEstimate: DEFAULT_MIN_DURATION } },
- }),
- );
- }
- } else {
- // Fallback to day-level scheduling if time calculation fails
- this._store.dispatch(
- PlannerActions.planTaskForDay({
- task: task,
- day: targetDay,
- isAddToTop: true,
- }),
- );
- }
- }
- }
-
- // Prevent schedule-week event drags from entering this drop list
- dropListEnterPredicate = (drag: CdkDrag): boolean => {
- const el = drag.element?.nativeElement as HTMLElement | null;
- // Deny if the dragged element is a schedule-event (handled internally by schedule-week)
- const isScheduleEvent = !!el && el.tagName.toLowerCase() === 'schedule-event';
- return !isScheduleEvent;
- };
-
private _calculateDropTime(): number | null {
// Use the exact timestamp that was calculated during the last drag move
// This ensures perfect consistency between preview and actual scheduling
@@ -328,6 +257,80 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
return timestamp;
}
+ 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 pointer = this._getPointerPosition(event);
+ const isInside = pointer ? this._isPointWithinDropZone(pointer.x, pointer.y) : false;
+ const dropTime = isInside ? this._calculateDropTime() : null;
+ const targetDay = this.daysToShow()[0];
+
+ this._stopScheduleMode();
+ this._externalDragService.setActiveTask(null);
+
+ if (!task || !targetDay) {
+ return;
+ }
+
+ if (dropTime !== null) {
+ 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 } },
+ }),
+ );
+ }
+ } else {
+ this._store.dispatch(
+ PlannerActions.planTaskForDay({
+ task,
+ day: targetDay,
+ isAddToTop: true,
+ }),
+ );
+ }
+ }
+
+ private _getPointerPosition(
+ event: MouseEvent | TouchEvent,
+ ): { x: number; y: number } | null {
+ if ('touches' in event) {
+ const touch = event.touches[0] ?? event.changedTouches?.[0];
+ if (!touch) {
+ return null;
+ }
+ return { x: touch.clientX, y: touch.clientY };
+ }
+ return { x: event.clientX, y: event.clientY };
+ }
+
+ private _isPointWithinDropZone(x: number, y: number): boolean {
+ const dropZoneEl = this.dropZoneRef?.nativeElement;
+ if (!dropZoneEl) {
+ return false;
+ }
+ const rect = dropZoneEl.getBoundingClientRect();
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+ }
+
+ private _formatPreviewTime(timestamp: number): string {
+ const date = new Date(timestamp);
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
+ }
+
private _scrollToCurrentTime(): void {
if (!this.scheduleWeekRef?.nativeElement) {
Log.warn('[ScheduleDayPanel] No scheduleWeekRef available');
@@ -406,6 +409,7 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
this.isDragging.set(false);
this.dragPreviewTime.set(null);
this.lastCalculatedTimestamp = null;
+ this._activeExternalTask = null;
this._applySchedulePreviewStyling(false);
this._cdr.markForCheck();
document.removeEventListener('mousemove', this._onGlobalPointerMove);
@@ -414,19 +418,11 @@ export class ScheduleDayPanelComponent implements AfterViewInit, OnDestroy {
private _onGlobalPointerMove = (ev: MouseEvent | TouchEvent): void => {
// If pointer is not over the side panel/drop zone anymore, leave schedule mode
- const clientX = 'touches' in ev ? ev.touches[0].clientX : ev.clientX;
- const clientY = 'touches' in ev ? ev.touches[0].clientY : ev.clientY;
- const dropZoneEl = this.dropList?.element?.nativeElement as HTMLElement | null;
- if (!dropZoneEl) {
+ const pointer = this._getPointerPosition(ev);
+ if (!pointer) {
return;
}
- const rect = dropZoneEl.getBoundingClientRect();
- const isInside =
- clientX >= rect.left &&
- clientX <= rect.right &&
- clientY >= rect.top &&
- clientY <= rect.bottom;
- if (!isInside) {
+ if (!this._isPointWithinDropZone(pointer.x, pointer.y)) {
// Re-enter Angular zone to ensure CD runs for OnPush
this._ngZone.run(() => this._stopScheduleMode());
}
diff --git a/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts b/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts
new file mode 100644
index 0000000000..a8312c1625
--- /dev/null
+++ b/src/app/features/schedule/schedule-week/schedule-external-drag.service.ts
@@ -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(null);
+
+ activeTask(): TaskWithSubTasks | null {
+ return this._activeTask();
+ }
+
+ setActiveTask(task: TaskWithSubTasks | null): void {
+ this._activeTask.set(task);
+ }
+}
diff --git a/src/app/features/tasks/task-list/task-list.component.html b/src/app/features/tasks/task-list/task-list.component.html
index eacdd8711b..81a6747113 100644
--- a/src/app/features/tasks/task-list/task-list.component.html
+++ b/src/app/features/tasks/task-list/task-list.component.html
@@ -41,6 +41,8 @@
cdkDrag
[cdkDragDisabled]="listModelId() === 'LATER_TODAY'"
[cdkDragData]="task"
+ (cdkDragStarted)="onDragStarted(task)"
+ (cdkDragEnded)="onDragEnded()"
>
}
diff --git a/src/app/features/tasks/task-list/task-list.component.ts b/src/app/features/tasks/task-list/task-list.component.ts
index 736332d158..5eb4e7d865 100644
--- a/src/app/features/tasks/task-list/task-list.component.ts
+++ b/src/app/features/tasks/task-list/task-list.component.ts
@@ -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([]);
@@ -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;