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:
Johannes Millan 2025-10-17 18:48:41 +02:00
commit a1f0ed1e94
41 changed files with 2663 additions and 707 deletions

View file

@ -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());
}
}

View file

@ -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');

View file

@ -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()),
),
);

View file

@ -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 = (

View file

@ -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>();

View file

@ -113,6 +113,7 @@
[kb]="kb"
[isRouteWithSidePanel]="isRouteWithSidePanel()"
[isWorkViewPage]="isWorkViewPage()"
[isShowScheduleDayPanel]="isShowScheduleDayPanel()"
[isShowTaskViewCustomizerPanel]="isShowTaskViewCustomizerPanel()"
[isShowIssuePanel]="isShowIssuePanel()"
[isShowNotes]="isShowNotes()"

View file

@ -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$);

View file

@ -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
);
});

View file

@ -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']);
});
});
});

View file

@ -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
? {}
: {

View file

@ -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

View file

@ -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();
}

View file

@ -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();

View file

@ -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;

View file

@ -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>

View file

@ -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);
}

View file

@ -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();
}
}
}

View file

@ -98,3 +98,5 @@
<div class="resize-grip"></div>
</div>
}
<ng-content></ng-content>

View file

@ -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;
}
}
}

View file

@ -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);
}
}

View file

@ -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],
}),
);
}
});
}
}
}

View file

@ -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;

View file

@ -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,
};
};

View file

@ -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>
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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();

View file

@ -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);
});
});
});

View file

@ -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,

View file

@ -41,6 +41,8 @@
cdkDrag
[cdkDragDisabled]="listModelId() === 'LATER_TODAY'"
[cdkDragData]="task"
(cdkDragStarted)="onDragStarted(task)"
(cdkDragEnded)="onDragEnded()"
>
</task>
}

View file

@ -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;

View file

@ -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>;

View file

@ -69,6 +69,7 @@ export const TaskSharedActions = createActionGroup({
id: string;
reminderId?: string;
isSkipToast?: boolean;
isLeaveInToday?: boolean;
}>(),
dismissReminderOnly: props<{

View file

@ -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',

View 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>
}

View 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;
}
}
}

View 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);
});
});

View 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);
}
}

View file

@ -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",

View file

@ -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;