diff --git a/apps/react-storybook/stories/scheduler/SchedulerHiddenWeekDays.stories.tsx b/apps/react-storybook/stories/scheduler/SchedulerHiddenWeekDays.stories.tsx new file mode 100644 index 000000000000..169e2826b67b --- /dev/null +++ b/apps/react-storybook/stories/scheduler/SchedulerHiddenWeekDays.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-webpack5"; +import dxScheduler from "devextreme/ui/scheduler"; +import { wrapDxWithReact } from "../utils"; +import { data, resources } from "./data"; + +const Scheduler = wrapDxWithReact(dxScheduler); + +const viewNames = ['day', 'week', 'workWeek', 'month', 'agenda', 'timelineDay', 'timelineWeek', 'timelineWorkWeek', 'timelineMonth']; + +const meta: Meta = { + title: 'Components/Scheduler/HiddenWeekDays', + component: Scheduler, + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Overview: Story = { + args: { + height: 600, + views: viewNames, + currentView: 'week', + currentDate: new Date(2021, 3, 26), + firstDayOfWeek: 0, + startDayHour: 9, + endDayHour: 22, + dataSource: data, + resources, + hiddenWeekDays: [], + }, + argTypes: { + height: { control: 'number' }, + views: { control: 'object' }, + hiddenWeekDays: { control: 'object' }, + currentView: { control: 'select', options: viewNames }, + }, +}; diff --git a/packages/devextreme-angular/src/ui/scheduler/index.ts b/packages/devextreme-angular/src/ui/scheduler/index.ts index 88811eb6e583..9d326c24d2f8 100644 --- a/packages/devextreme-angular/src/ui/scheduler/index.ts +++ b/packages/devextreme-angular/src/ui/scheduler/index.ts @@ -24,7 +24,7 @@ import { import type dxSortable from 'devextreme/ui/sortable'; import type dxDraggable from 'devextreme/ui/draggable'; -import type { default as dxScheduler, AllDayPanelMode, ViewType, dxSchedulerAppointment, AppointmentFormProperties, CellAppointmentsLimit, AppointmentAddedEvent, AppointmentAddingEvent, AppointmentClickEvent, AppointmentContextMenuEvent, AppointmentDblClickEvent, AppointmentDeletedEvent, AppointmentDeletingEvent, AppointmentFormOpeningEvent, AppointmentRenderedEvent, AppointmentTooltipShowingEvent, AppointmentUpdatedEvent, AppointmentUpdatingEvent, CellClickEvent, CellContextMenuEvent, ContentReadyEvent, DisposingEvent, InitializedEvent, OptionChangedEvent, RecurrenceEditMode, dxSchedulerScrolling, SnapToCellsMode, dxSchedulerToolbar } from 'devextreme/ui/scheduler'; +import type { default as dxScheduler, AllDayPanelMode, ViewType, dxSchedulerAppointment, AppointmentFormProperties, DayOfWeek, CellAppointmentsLimit, AppointmentAddedEvent, AppointmentAddingEvent, AppointmentClickEvent, AppointmentContextMenuEvent, AppointmentDblClickEvent, AppointmentDeletedEvent, AppointmentDeletingEvent, AppointmentFormOpeningEvent, AppointmentRenderedEvent, AppointmentTooltipShowingEvent, AppointmentUpdatedEvent, AppointmentUpdatingEvent, CellClickEvent, CellContextMenuEvent, ContentReadyEvent, DisposingEvent, InitializedEvent, OptionChangedEvent, RecurrenceEditMode, dxSchedulerScrolling, SnapToCellsMode, dxSchedulerToolbar } from 'devextreme/ui/scheduler'; import type { event } from 'devextreme/events/events.types'; import type { default as DataSource, DataSourceOptions } from 'devextreme/data/data_source'; import type { Store } from 'devextreme/data/store'; @@ -515,6 +515,16 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh } + + @Input() + get hiddenWeekDays(): Array { + return this._getOption('hiddenWeekDays'); + } + set hiddenWeekDays(value: Array) { + this._setOption('hiddenWeekDays', value); + } + + /** * [descr:WidgetOptions.hint] @@ -894,10 +904,10 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh */ @Input() - get views(): Array | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[] { + get views(): Array | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, hiddenWeekDays?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[] { return this._getOption('views'); } - set views(value: Array | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[]) { + set views(value: Array | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, hiddenWeekDays?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[]) { this._setOption('views', value); } @@ -1274,6 +1284,13 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh */ @Output() heightChange: EventEmitter; + /** + + * This member supports the internal infrastructure and is not intended to be used directly from your code. + + */ + @Output() hiddenWeekDaysChange: EventEmitter>; + /** * This member supports the internal infrastructure and is not intended to be used directly from your code. @@ -1482,7 +1499,7 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh * This member supports the internal infrastructure and is not intended to be used directly from your code. */ - @Output() viewsChange: EventEmitter | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[]>; + @Output() viewsChange: EventEmitter | string> | { agendaDuration?: number, allDayPanelMode?: AllDayPanelMode, appointmentCollectorTemplate?: any, appointmentTemplate?: any, appointmentTooltipTemplate?: any, cellDuration?: number, dataCellTemplate?: any, dateCellTemplate?: any, endDayHour?: number, firstDayOfWeek?: FirstDayOfWeek | undefined, groupByDate?: boolean, groupOrientation?: Orientation, groups?: Array, hiddenWeekDays?: Array, intervalCount?: number, maxAppointmentsPerCell?: CellAppointmentsLimit | number, name?: string | undefined, offset?: number, resourceCellTemplate?: any, scrolling?: dxSchedulerScrolling, snapToCellsMode?: SnapToCellsMode, startDate?: Date | number | string | undefined, startDayHour?: number, timeCellTemplate?: any, type?: undefined | ViewType }[]>; /** @@ -1558,6 +1575,7 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh { emit: 'groupByDateChange' }, { emit: 'groupsChange' }, { emit: 'heightChange' }, + { emit: 'hiddenWeekDaysChange' }, { emit: 'hintChange' }, { emit: 'indicatorUpdateIntervalChange' }, { emit: 'maxChange' }, @@ -1610,6 +1628,7 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh super.ngOnChanges(changes); this.setupChanges('dataSource', changes); this.setupChanges('groups', changes); + this.setupChanges('hiddenWeekDays', changes); this.setupChanges('resources', changes); this.setupChanges('selectedCellData', changes); this.setupChanges('views', changes); @@ -1624,6 +1643,7 @@ export class DxSchedulerComponent extends DxComponent implements OnDestroy, OnCh ngDoCheck() { this._idh.doCheck('dataSource'); this._idh.doCheck('groups'); + this._idh.doCheck('hiddenWeekDays'); this._idh.doCheck('resources'); this._idh.doCheck('selectedCellData'); this._idh.doCheck('views'); diff --git a/packages/devextreme-angular/src/ui/scheduler/nested/view-dxi.ts b/packages/devextreme-angular/src/ui/scheduler/nested/view-dxi.ts index daf41c5c48c8..ac6bc16347a1 100644 --- a/packages/devextreme-angular/src/ui/scheduler/nested/view-dxi.ts +++ b/packages/devextreme-angular/src/ui/scheduler/nested/view-dxi.ts @@ -12,7 +12,7 @@ import { -import type { AllDayPanelMode, CellAppointmentsLimit, dxSchedulerScrolling, SnapToCellsMode, ViewType } from 'devextreme/ui/scheduler'; +import type { AllDayPanelMode, DayOfWeek, CellAppointmentsLimit, dxSchedulerScrolling, SnapToCellsMode, ViewType } from 'devextreme/ui/scheduler'; import type { FirstDayOfWeek, Orientation } from 'devextreme/common'; import { @@ -142,6 +142,14 @@ export class DxiSchedulerViewComponent extends CollectionNestedOption { this._setOption('groups', value); } + @Input() + get hiddenWeekDays(): Array { + return this._getOption('hiddenWeekDays'); + } + set hiddenWeekDays(value: Array) { + this._setOption('hiddenWeekDays', value); + } + @Input() get intervalCount(): number { return this._getOption('intervalCount'); diff --git a/packages/devextreme-metadata/make-angular-metadata.ts b/packages/devextreme-metadata/make-angular-metadata.ts index 8754150e81dc..dc49256b87db 100644 --- a/packages/devextreme-metadata/make-angular-metadata.ts +++ b/packages/devextreme-metadata/make-angular-metadata.ts @@ -65,6 +65,7 @@ Ng.makeMetadata({ removeMembers(/\/scheduler:dxSchedulerOptions\.editing\.popup/), removeMembers(/\/scheduler:dxSchedulerOptions\.resources\.icon/), removeMembers(/\/scheduler:.*\.snapToCellsMode/), + removeMembers(/\/scheduler:.*\.hiddenWeekDays/), removeMembers(/\/stepper:/), removeMembers(/\/speech_to_text:/), removeMembers(/\/tree_list:dxTreeListColumnButton.onClick/), diff --git a/packages/devextreme-react/src/scheduler.ts b/packages/devextreme-react/src/scheduler.ts index 1e62a2f2a4ff..94046b0c9bab 100644 --- a/packages/devextreme-react/src/scheduler.ts +++ b/packages/devextreme-react/src/scheduler.ts @@ -8,7 +8,7 @@ import dxScheduler, { import { Component as BaseComponent, IHtmlOptions, ComponentRef, NestedComponentMeta } from "./core/component"; import NestedOption from "./core/nested-option"; -import type { ViewType, AppointmentAddedEvent, AppointmentAddingEvent, AppointmentClickEvent, AppointmentContextMenuEvent, AppointmentDblClickEvent, AppointmentDeletedEvent, AppointmentDeletingEvent, AppointmentFormOpeningEvent, AppointmentRenderedEvent, AppointmentTooltipShowingEvent, AppointmentUpdatedEvent, AppointmentUpdatingEvent, CellClickEvent, CellContextMenuEvent, ContentReadyEvent, DisposingEvent, InitializedEvent, AppointmentFormProperties, AppointmentFormIconsShowMode, SchedulerPredefinedToolbarItem, DateNavigatorItemProperties, SchedulerPredefinedDateNavigatorItem, dxSchedulerToolbarItem, AllDayPanelMode, AppointmentCollectorTemplateData, AppointmentTemplateData, AppointmentTooltipTemplateData, CellAppointmentsLimit, dxSchedulerScrolling, SnapToCellsMode } from "devextreme/ui/scheduler"; +import type { ViewType, AppointmentAddedEvent, AppointmentAddingEvent, AppointmentClickEvent, AppointmentContextMenuEvent, AppointmentDblClickEvent, AppointmentDeletedEvent, AppointmentDeletingEvent, AppointmentFormOpeningEvent, AppointmentRenderedEvent, AppointmentTooltipShowingEvent, AppointmentUpdatedEvent, AppointmentUpdatingEvent, CellClickEvent, CellContextMenuEvent, ContentReadyEvent, DisposingEvent, InitializedEvent, AppointmentFormProperties, AppointmentFormIconsShowMode, SchedulerPredefinedToolbarItem, DateNavigatorItemProperties, SchedulerPredefinedDateNavigatorItem, dxSchedulerToolbarItem, AllDayPanelMode, AppointmentCollectorTemplateData, AppointmentTemplateData, AppointmentTooltipTemplateData, DayOfWeek, CellAppointmentsLimit, dxSchedulerScrolling, SnapToCellsMode } from "devextreme/ui/scheduler"; import type { ContentReadyEvent as ButtonContentReadyEvent, DisposingEvent as ButtonDisposingEvent, InitializedEvent as ButtonInitializedEvent, dxButtonOptions, ClickEvent, OptionChangedEvent } from "devextreme/ui/button"; import type { ContentReadyEvent as FormContentReadyEvent, DisposingEvent as FormDisposingEvent, InitializedEvent as FormInitializedEvent, FormItemType, FormPredefinedButtonItem, OptionChangedEvent as FormOptionChangedEvent, dxFormSimpleItem, dxFormGroupItem, dxFormTabbedItem, dxFormEmptyItem, dxFormButtonItem, LabelLocation, FormLabelMode, EditorEnterKeyEvent, FieldDataChangedEvent, SmartPastedEvent, SmartPastingEvent, FormItemComponent } from "devextreme/ui/form"; import type { ContentReadyEvent as ButtonGroupContentReadyEvent, DisposingEvent as ButtonGroupDisposingEvent, InitializedEvent as ButtonGroupInitializedEvent, OptionChangedEvent as ButtonGroupOptionChangedEvent, dxButtonGroupItem, ItemClickEvent, SelectionChangedEvent } from "devextreme/ui/button_group"; @@ -1454,6 +1454,7 @@ type IViewProps = React.PropsWithChildren<{ groupByDate?: boolean; groupOrientation?: Orientation; groups?: Array; + hiddenWeekDays?: Array; intervalCount?: number; maxAppointmentsPerCell?: CellAppointmentsLimit | number; name?: string | undefined; diff --git a/packages/devextreme-vue/src/scheduler.ts b/packages/devextreme-vue/src/scheduler.ts index 7d468cb8c2b6..c2cd393d4439 100644 --- a/packages/devextreme-vue/src/scheduler.ts +++ b/packages/devextreme-vue/src/scheduler.ts @@ -10,6 +10,7 @@ import { AllDayPanelMode, ViewType, dxSchedulerAppointment, + DayOfWeek, CellAppointmentsLimit, AppointmentAddedEvent, AppointmentAddingEvent, @@ -161,6 +162,7 @@ type AccessibleOptions = Pick>, height: [Number, String], + hiddenWeekDays: Array as PropType>, hint: String, indicatorUpdateInterval: Number, max: [Date, Number, String], @@ -331,6 +334,7 @@ const componentConfig = { "update:groupByDate": null, "update:groups": null, "update:height": null, + "update:hiddenWeekDays": null, "update:hint": null, "update:indicatorUpdateInterval": null, "update:max": null, @@ -1774,6 +1778,7 @@ const DxViewConfig = { "update:groupByDate": null, "update:groupOrientation": null, "update:groups": null, + "update:hiddenWeekDays": null, "update:intervalCount": null, "update:maxAppointmentsPerCell": null, "update:name": null, @@ -1800,6 +1805,7 @@ const DxViewConfig = { groupByDate: Boolean, groupOrientation: String as PropType, groups: Array as PropType>, + hiddenWeekDays: Array as PropType>, intervalCount: Number, maxAppointmentsPerCell: [String, Number] as PropType, name: String, diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__snapshots__/santiago_timezone.test.ts.snap b/packages/devextreme/js/__internal/scheduler/__tests__/__snapshots__/santiago_timezone.test.ts.snap index 2190e15e6280..7c2c8817b56c 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__snapshots__/santiago_timezone.test.ts.snap +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__snapshots__/santiago_timezone.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`scheduler should render correct workspace in Santiago DST for view: Day DST 1`] = ` [ @@ -307,7 +307,7 @@ exports[`scheduler should render correct workspace in Santiago DST for view: Tim "Wed 4", "Thu 5", "Fri 6", - "Sat 7", + "Mon 9", "12:00 AM", "6:00 AM", "12:00 PM", diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts index 17098be9585a..03c0cad2e4dc 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.base.test.ts @@ -44,6 +44,7 @@ type WorkspaceConstructor = new (container: Element, options?: any) => T; const createWorkspace = ( WorkSpace: WorkspaceConstructor, currentView: string, + options?: any, ): { workspace: T; container: Element } => { const container = document.createElement('div'); const workspace = new WorkSpace(container, { @@ -52,6 +53,7 @@ const createWorkspace = ( currentDate: new Date(2017, 4, 25), firstDayOfWeek: 0, getResourceManager: () => getResourceManagerMock([]), + ...options, }); (workspace as any)._isVisible = () => true; expect(container.classList).toContain('dx-scheduler-work-space'); @@ -193,3 +195,61 @@ describe('scheduler workspace scrollTo', () => { expect(scrollableContainer.scrollLeft).toBeCloseTo(-11125); }); }); + +describe('scheduler workspace skipped days support', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + }); + + it('should count configured skipped days in week workspace interval math', () => { + const { workspace } = createWorkspace(SchedulerWorkSpaceWeek, 'week', { + skippedDays: [1, 3], + }); + + expect((workspace as any).getSkippedDaysCount(new Date(2026, 3, 5), 7)).toBe(2); + }); + + it('should use full week layout for work week when skippedDays override is empty', () => { + const { workspace } = createWorkspace(SchedulerWorkSpaceWorkWeek, 'workWeek', { + currentDate: new Date(2026, 3, 1), // Wednesday + firstDayOfWeek: 0, // Sunday + skippedDays: [], + }); + + expect(workspace.getStartViewDate()).toEqual(new Date(2026, 2, 29)); + expect((workspace as any)._getCellCount()).toBe(7); + }); + + it('should use custom skippedDays in work week runtime layout', () => { + const { workspace } = createWorkspace(SchedulerWorkSpaceWorkWeek, 'workWeek', { + currentDate: new Date(2026, 3, 1), // Wednesday + firstDayOfWeek: 0, // Sunday + skippedDays: [3], // Wednesday + }); + + expect(workspace.getStartViewDate()).toEqual(new Date(2026, 2, 29)); + expect((workspace as any)._getCellCount()).toBe(6); + }); + + it('should skip configured hidden days when incrementing timeline header dates', () => { + const { workspace } = createWorkspace(SchedulerTimelineWeek, 'timelineWeek', { + skippedDays: [3], + }); + const date = new Date(2026, 3, 7); // Tuesday + + (workspace as any).incrementDate(date); + + expect(date).toEqual(new Date(2026, 3, 9)); // Thursday + }); + + it('should skip hidden days when incrementing timeline day dates', () => { + const { workspace } = createWorkspace(SchedulerTimelineDay, 'timelineDay', { + skippedDays: [0, 6], + }); + const date = new Date(2026, 3, 10); // Friday + + (workspace as any).incrementDate(date); + + expect(date).toEqual(new Date(2026, 3, 13)); // Monday + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/header/m_header.ts b/packages/devextreme/js/__internal/scheduler/header/m_header.ts index b9b5b19e14fe..4c2267dc6c77 100644 --- a/packages/devextreme/js/__internal/scheduler/header/m_header.ts +++ b/packages/devextreme/js/__internal/scheduler/header/m_header.ts @@ -57,7 +57,7 @@ export class SchedulerHeader extends Widget { } public getIntervalOptions(date: Date): IntervalOptions { - const { currentView, firstDayOfWeek } = this.option(); + const { currentView, firstDayOfWeek, skippedDays } = this.option(); const step = getStep(currentView.type); return { @@ -66,6 +66,7 @@ export class SchedulerHeader extends Widget { firstDayOfWeek, intervalCount: currentView.intervalCount, agendaDuration: currentView.agendaDuration, + skippedDays, }; } diff --git a/packages/devextreme/js/__internal/scheduler/header/m_utils.test.ts b/packages/devextreme/js/__internal/scheduler/header/m_utils.test.ts new file mode 100644 index 000000000000..4d08bac989c5 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/header/m_utils.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getCaptionInterval, getNextIntervalDate } from './m_utils'; + +describe('agenda hiddenWeekDays support in header utils', () => { + const skippedDays: number[] = [0, 6]; + const options = { + date: new Date(2026, 3, 11), + step: 'agenda' as const, + intervalCount: 1, + agendaDuration: 3, + skippedDays, + }; + + it('should build caption interval by calendar days', () => { + expect(getCaptionInterval(options)).toEqual({ + startDate: new Date(2026, 3, 11), + endDate: new Date(2026, 3, 13, 23, 59, 59, 999), + }); + }); + + it('should navigate to next agenda interval by calendar days', () => { + expect(getNextIntervalDate(options, 1)).toEqual(new Date(2026, 3, 14)); + }); + + it('should navigate to previous agenda interval by calendar days', () => { + expect(getNextIntervalDate(options, -1)).toEqual(new Date(2026, 3, 8)); + }); +}); + +describe('day hiddenWeekDays support in header utils', () => { + it('should shift day caption to the next visible day', () => { + expect(getCaptionInterval({ + date: new Date(2026, 3, 11), // Saturday + step: 'day', + intervalCount: 1, + skippedDays: [0, 6], + })).toEqual({ + startDate: new Date(2026, 3, 13), + endDate: new Date(2026, 3, 13, 23, 59, 59, 999), + }); + }); + + it('should navigate to the next visible day interval', () => { + expect(getNextIntervalDate({ + date: new Date(2026, 3, 10), // Friday + step: 'day', + intervalCount: 3, + skippedDays: [0, 6], + }, 1)).toEqual(new Date(2026, 3, 15)); + }); + + it('should navigate from a hidden day based on the visible interval', () => { + expect(getNextIntervalDate({ + date: new Date(2026, 3, 11), // Saturday + step: 'day', + intervalCount: 1, + skippedDays: [0, 6], + }, 1)).toEqual(new Date(2026, 3, 14)); + }); +}); + +describe('workWeek hiddenWeekDays support in header utils', () => { + it('should keep Mon-Fri caption for default skippedDays', () => { + expect(getCaptionInterval({ + date: new Date(2026, 3, 8), // Wednesday + step: 'workWeek', + intervalCount: 1, + skippedDays: [0, 6], + firstDayOfWeek: 0, + })).toEqual({ + startDate: new Date(2026, 3, 6), + endDate: new Date(2026, 3, 10, 23, 59, 59, 999), + }); + }); + + it('should use full week caption when skippedDays override is empty', () => { + expect(getCaptionInterval({ + date: new Date(2026, 3, 8), // Wednesday + step: 'workWeek', + intervalCount: 1, + skippedDays: [], + firstDayOfWeek: 0, + })).toEqual({ + startDate: new Date(2026, 3, 5), + endDate: new Date(2026, 3, 11, 23, 59, 59, 999), + }); + }); + + it('should use first and last visible days for custom skippedDays', () => { + expect(getCaptionInterval({ + date: new Date(2026, 3, 8), // Wednesday + step: 'workWeek', + intervalCount: 1, + skippedDays: [1, 2], + firstDayOfWeek: 0, + })).toEqual({ + startDate: new Date(2026, 3, 5), + endDate: new Date(2026, 3, 11, 23, 59, 59, 999), + }); + }); +}); + +describe('week hiddenWeekDays support in header utils', () => { + it('should use first and last visible day for week caption', () => { + expect(getCaptionInterval({ + date: new Date(2026, 3, 8), // Wednesday + step: 'week', + intervalCount: 1, + skippedDays: [0, 1], + firstDayOfWeek: 1, // Monday + })).toEqual({ + startDate: new Date(2026, 3, 7), + endDate: new Date(2026, 3, 11, 23, 59, 59, 999), + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/header/m_utils.ts b/packages/devextreme/js/__internal/scheduler/header/m_utils.ts index f5d981394439..32ba82f1f869 100644 --- a/packages/devextreme/js/__internal/scheduler/header/m_utils.ts +++ b/packages/devextreme/js/__internal/scheduler/header/m_utils.ts @@ -7,13 +7,15 @@ import type { BaseFormat } from '@ts/core/localization/date'; import { camelize } from '@ts/core/utils/m_inflector'; import type { IntervalOptions, Step } from '@ts/scheduler/header/types'; import type { NormalizedView, RawViewType, ViewType } from '@ts/scheduler/utils/options/types'; +import { + getFirstVisibleDate, + isDateSkipped, +} from '@ts/scheduler/utils/skipped_days'; import type { Direction } from './constants'; const DAY_FORMAT = 'd'; -const DAYS_IN_WORK_WEEK = 5; - const { correctDateWithUnitBeginning: getPeriodStart, getFirstWeekDate: getWeekStart, @@ -29,15 +31,14 @@ const MS_DURATION = { milliseconds: 1 }; const DAY_DURATION = { days: 1 }; const WEEK_DURATION = { days: 7 }; -const SATURDAY_INDEX = 6; -const SUNDAY_INDEX = 0; - const subMS = (date: Date): Date => addDateInterval(date, MS_DURATION, -1); const addMS = (date: Date): Date => addDateInterval(date, MS_DURATION, 1); const nextDay = (date: Date): Date => addDateInterval(date, DAY_DURATION, 1); +const prevDay = (date: Date): Date => addDateInterval(date, DAY_DURATION, -1); + export const nextWeek = (date: Date): Date => addDateInterval(date, WEEK_DURATION, 1); const nextMonth = (date: Date): Date => { @@ -46,47 +47,61 @@ const nextMonth = (date: Date): Date => { return addDateInterval(date, { days }, 1); }; -const isWeekend = (date: Date): boolean => [SATURDAY_INDEX, SUNDAY_INDEX].includes(date.getDay()); +const getDateAfterWeek = ( + startDate: Date, + firstDayOfWeek: number | undefined, + skippedDays: number[], +): Date => { + const weekStart = getWeekStart(startDate, firstDayOfWeek); + let lastVisibleDate = addDateInterval(weekStart, { days: 6 }, 1); -const getWorkWeekStart = (firstDayOfWeek: Date): Date => { - let date = new Date(firstDayOfWeek); - while (isWeekend(date)) { - date = nextDay(date); + while (isDateSkipped(lastVisibleDate, skippedDays)) { + lastVisibleDate = addDateInterval(lastVisibleDate, DAY_DURATION, -1); } - return date; + return nextDay(lastVisibleDate); }; -const getDateAfterWorkWeek = (workWeekStart: Date): Date => { - let date = new Date(workWeekStart); +const nextAgendaStart = ( + date: Date, + agendaDuration: number, +): Date => addDateInterval(date, { days: agendaDuration }, 1); - let workDaysCount = 0; - while (workDaysCount < DAYS_IN_WORK_WEEK) { - if (!isWeekend(date)) { - workDaysCount += 1; - } +const getDateAfterVisibleDays = ( + startDate: Date, + dayCount: number, + skippedDays: number[], + direction: Direction, +): Date => { + const dateStep = direction === 1 ? nextDay : prevDay; + let date = getFirstVisibleDate(new Date(startDate), skippedDays, dateStep); - date = nextDay(date); + for (let i = 0; i < dayCount; i += 1) { + date = getFirstVisibleDate(dateStep(date), skippedDays, dateStep); } return date; }; -const nextAgendaStart = ( - date: Date, - agendaDuration: number, -): Date => addDateInterval(date, { days: agendaDuration }, 1); - const getIntervalStartDate = (options: IntervalOptions): Date => { - const { date, step, firstDayOfWeek } = options; + const { + date, step, firstDayOfWeek, skippedDays, + } = options; switch (step) { case 'day': - case 'week': + return getFirstVisibleDate( + getPeriodStart(date, step, false, firstDayOfWeek) as Date, + skippedDays, + nextDay, + ); case 'month': return getPeriodStart(date, step, false, firstDayOfWeek) as Date; - case 'workWeek': - return getWorkWeekStart(getWeekStart(date, firstDayOfWeek)); + case 'week': + case 'workWeek': { + const weekStart = getPeriodStart(date, 'week', false, firstDayOfWeek) as Date; + return getFirstVisibleDate(weekStart, skippedDays, nextDay); + } case 'agenda': return new Date(date); default: @@ -98,32 +113,38 @@ const getPeriodEndDate = ( currentPeriodStartDate: Date, step: Step, agendaDuration: number, + skippedDays: number[], + firstDayOfWeek: number | undefined, ): Date => { const calculators: Record Date> = { day: () => nextDay(currentPeriodStartDate), - week: () => nextWeek(currentPeriodStartDate), + week: () => getDateAfterWeek(currentPeriodStartDate, firstDayOfWeek, skippedDays), month: () => nextMonth(currentPeriodStartDate), - workWeek: () => getDateAfterWorkWeek(currentPeriodStartDate), + workWeek: () => getDateAfterWeek(currentPeriodStartDate, firstDayOfWeek, skippedDays), agenda: () => nextAgendaStart(currentPeriodStartDate, agendaDuration), }; return subMS(calculators[step]()); }; -const getNextPeriodStartDate = (currentPeriodEndDate: Date, step: Step): Date => { +const getNextPeriodStartDate = ( + currentPeriodEndDate: Date, + step: Step, + skippedDays: number[], +): Date => { let date = addMS(currentPeriodEndDate); - if (step === 'workWeek') { - while (isWeekend(date)) { - date = nextDay(date); - } + if (step === 'day' || step === 'week' || step === 'workWeek') { + date = getFirstVisibleDate(date, skippedDays, nextDay); } return date; }; const getIntervalEndDate = (startDate: Date, options: IntervalOptions): Date => { - const { intervalCount, step, agendaDuration } = options; + const { + intervalCount, step, agendaDuration, skippedDays, firstDayOfWeek, + } = options; let periodStartDate = new Date(startDate); let periodEndDate = new Date(startDate); @@ -132,9 +153,15 @@ const getIntervalEndDate = (startDate: Date, options: IntervalOptions): Date => for (let i = 0; i < intervalCount; i += 1) { periodStartDate = nextPeriodStartDate; - periodEndDate = getPeriodEndDate(periodStartDate, step, agendaDuration ?? 0); + periodEndDate = getPeriodEndDate( + periodStartDate, + step, + agendaDuration ?? 0, + skippedDays, + firstDayOfWeek, + ); - nextPeriodStartDate = getNextPeriodStartDate(periodEndDate, step); + nextPeriodStartDate = getNextPeriodStartDate(periodEndDate, step, skippedDays); } return periodEndDate; @@ -163,15 +190,19 @@ const getNextMonthDate = (date: Date, intervalCount: number, direction: Directio export const getNextIntervalDate = (options: IntervalOptions, direction: Direction): Date => { const { - date, step, intervalCount, agendaDuration, + date, step, intervalCount, agendaDuration, skippedDays, } = options; let dayDuration = 0; // eslint-disable-next-line default-case switch (step) { case 'day': - dayDuration = Number(intervalCount); - break; + return getDateAfterVisibleDays( + date, + intervalCount, + skippedDays, + direction, + ); case 'week': case 'workWeek': dayDuration = 7 * intervalCount; diff --git a/packages/devextreme/js/__internal/scheduler/header/types.ts b/packages/devextreme/js/__internal/scheduler/header/types.ts index 9fe0089ae77e..7fdb106fa6be 100644 --- a/packages/devextreme/js/__internal/scheduler/header/types.ts +++ b/packages/devextreme/js/__internal/scheduler/header/types.ts @@ -5,6 +5,7 @@ import type { NormalizedView, SafeSchedulerOptions } from '../utils/options/type export interface HeaderOptions { currentView: NormalizedView; + skippedDays: number[]; views: NormalizedView[]; currentDate: Date; min?: Date; @@ -30,6 +31,7 @@ export interface IntervalOptions { firstDayOfWeek?: number; intervalCount: number; agendaDuration?: number; + skippedDays: number[]; } export interface HeaderCalendarOptions { diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index b04bf4ca533c..ce2c478a4817 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -314,6 +314,9 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.updateOption('header', 'views', this.views); } break; + case 'hiddenWeekDays': + this.repaint(); + break; case 'useDropDownViewSwitcher': this.updateOption('header', name, value); break; @@ -511,6 +514,8 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.updateOption('workSpace', name, value); this.repaint(); break; + case 'skippedDays': + break; case 'indicatorTime': this.updateOption('workSpace', name, value); this.updateOption('header', name, value); @@ -1278,6 +1283,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { private headerConfig(): HeaderOptions { return { currentView: this.currentView, + skippedDays: this.getViewOption('hiddenWeekDays') as number[], views: this.views, currentDate: this.getViewOption('currentDate'), min: this.getViewOption('min'), @@ -1434,6 +1440,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.option('selectedCellData', args.selectedCellData); }, groupByDate: this.getViewOption('groupByDate'), + skippedDays: this.getViewOption('hiddenWeekDays') as number[], scrolling, draggingMode: this.option('_draggingMode'), timeZoneCalculator: this.timeZoneCalculator, @@ -1455,6 +1462,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { result.onCellClick = this._createActionByOption('onCellClick'); result.onCellContextMenu = this._createActionByOption('onCellContextMenu'); result.currentDate = this.getViewOption('currentDate'); + result.skippedDays = this.getViewOption('hiddenWeekDays') as number[]; result.hoursInterval = result.cellDuration / 60; result.allDayExpanded = false; result.dataCellTemplate = result.dataCellTemplate ? this._getTemplate(result.dataCellTemplate) : null; diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts index 2fed0b1a6b69..4f53f96fdf21 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/__tests__/base.test.ts @@ -13,12 +13,13 @@ import { getKeyByGroup, getSkippedHoursInRange, isAppointmentTakesAllDay, - isDataOnWeekend, isGroupingByDate, isHorizontalGroupingApplied, isVerticalGroupingApplied, } from '../index'; +const isWeekend = (date: Date): boolean => [0, 6].includes(date.getDay()); + describe('base utils', () => { describe('getDatesWithoutTime', () => { it('should trim dates correctly', () => { @@ -200,7 +201,7 @@ describe('base utils', () => { describe('default', () => { it('should skip large interval', () => { const mockViewDataProvider = { - isSkippedDate: (date: Date) => date.getDay() >= 6, + isDateSkipped: (date: Date) => date.getDay() >= 6, getViewOptions: () => ({ startDayHour: 0, endDayHour: 24, @@ -221,7 +222,7 @@ describe('base utils', () => { it('should skip 2 weekend days if startDate and endDate inside weekend', () => { const mockViewDataProvider = { - isSkippedDate: (date: Date) => isDataOnWeekend(date), + isDateSkipped: (date: Date) => isWeekend(date), getViewOptions: () => ({ startDayHour: 0, endDayHour: 24, @@ -243,7 +244,7 @@ describe('base utils', () => { describe('border conditions', () => { const mockViewDataProvider = { - isSkippedDate: (date: Date) => isDataOnWeekend(date), + isDateSkipped: (date: Date) => isWeekend(date), getViewOptions: () => ({ startDayHour: 0, endDayHour: 24, @@ -374,7 +375,7 @@ describe('base utils', () => { endDayHour, }) => { const mockViewDataProvider = { - isSkippedDate: (date: Date) => isDataOnWeekend(date), + isDateSkipped: (date: Date) => isWeekend(date), getViewOptions: () => ({ startDayHour, endDayHour, @@ -413,7 +414,7 @@ describe('base utils', () => { expectedHours, }) => { const mockViewDataProvider = { - isSkippedDate: (date: Date) => isDataOnWeekend(date), + isDateSkipped: (date: Date) => isWeekend(date), getViewOptions: () => ({ startDayHour: 11, endDayHour: 19, diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.test.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.test.ts index 0f93760824ba..f9e08019185e 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.test.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from '@jest/globals'; -import { calculateRows } from './agenda'; +import { + calculateEndViewDate, + calculateRows, + calculateStartViewDate, + getDateByIndex, +} from './agenda'; const items = [ { groupIndex: 0, startDateUTC: Date.UTC(2020, 0, 10, 5) }, @@ -27,4 +32,38 @@ describe('calculateRows', () => { [0, 2, 1, 0, 2, 0, 0], ]); }); + + it('should keep calendar offsets inside agenda duration window', () => { + expect(calculateRows(items.slice(1, 2), 3, new Date(2020, 0, 10), 1)).toEqual([ + [0, 1, 0], + ]); + }); + + it('should map Monday to the third calendar day of Sat-Mon window', () => { + expect(calculateRows([ + { groupIndex: 0, startDateUTC: Date.UTC(2020, 0, 13, 5) }, + ] as any[], 3, new Date(2020, 0, 11), 1)).toEqual([ + [0, 0, 1], + ]); + }); +}); + +describe('agenda calendar range', () => { + it('should keep startViewDate on current date', () => { + expect(calculateStartViewDate(new Date(2020, 0, 11, 9), 9)).toEqual( + new Date(2020, 0, 11, 9), + ); + }); + + it('should return calendar day by row index', () => { + expect(getDateByIndex(new Date(2020, 0, 10, 9), 2)).toEqual( + new Date(2020, 0, 12, 9), + ); + }); + + it('should calculate endViewDate by calendar days', () => { + expect(calculateEndViewDate(new Date(2020, 0, 10, 9), 18, 3)).toEqual( + new Date(2020, 0, 12, 17, 59), + ); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.ts index 8bddcb45b093..c6d475fd17dd 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/agenda.ts @@ -2,23 +2,45 @@ import timeZoneUtils from '../../m_utils_time_zone'; import type { ListEntity } from '../../view_model/types'; import { setOptionHour } from './base'; -export const calculateStartViewDate = (currentDate: Date, startDayHour: number): Date => { +export const calculateStartViewDate = ( + currentDate: Date, + startDayHour: number, +): Date => { const validCurrentDate = new Date(currentDate); - return setOptionHour(validCurrentDate, startDayHour); }; const getDayStart = (date: Date | number): number => new Date(date).setUTCHours(0, 0, 0, 0); +export const getDateByIndex = ( + startViewDate: Date, + index: number, +): Date => { + const date = new Date(startViewDate); + date.setDate(date.getDate() + index); + return date; +}; + +export const calculateEndViewDate = ( + startViewDate: Date, + endDayHour: number, + agendaDuration: number, +): Date => { + const lastVisibleDate = getDateByIndex( + startViewDate, + Math.max(agendaDuration - 1, 0), + ); + const endViewDate = setOptionHour(lastVisibleDate, endDayHour); + + return new Date(endViewDate.getTime() - 60000); +}; + export const calculateRows = ( appointments: ListEntity[], agendaDuration: number, - currentDate: Date, + startViewDate: Date, groupCount: number, ): number[][] => { - const dayMs = getDayStart( - timeZoneUtils.createUTCDateWithLocalOffset(currentDate), - ); const intervalsStartMap = new Map(); const result = Array.from( { length: groupCount || 1 }, @@ -26,8 +48,9 @@ export const calculateRows = ( ); for (let i = 0; i < agendaDuration; i += 1) { - const day = new Date(dayMs); - intervalsStartMap.set(day.setUTCDate(day.getUTCDate() + i), i); + const date = getDateByIndex(startViewDate, i); + const dayStart = getDayStart(timeZoneUtils.createUTCDateWithLocalOffset(date)); + intervalsStartMap.set(dayStart, i); } appointments.forEach((appointment) => { diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts index 2487bac3a6d9..fab274a20b3d 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/base.ts @@ -28,8 +28,6 @@ import { VIEWS } from '../../utils/options/constants_view'; const toMs = dateUtils.dateToMilliseconds; const DAY_HOURS = 24; const HOUR_IN_MS = 1000 * 60 * 60; -const SATURDAY_INDEX = 6; -const SUNDAY_INDEX = 0; const getDurationInHours = ( startDate: Date, @@ -386,7 +384,7 @@ export const getSkippedHoursInRange = ( const dayHours = isAllDay ? DAY_HOURS : endDayHour - startDayHour; while (currentDate < endDateWithStartHour) { - if (viewDataProvider.isSkippedDate(currentDate)) { + if (viewDataProvider.isDateSkipped(currentDate)) { result += dayHours; } @@ -396,7 +394,7 @@ export const getSkippedHoursInRange = ( const startDateHours = startDate.getHours(); const endDateHours = endDate.getHours() + (endDate.getTime() % HOUR_IN_MS) / HOUR_IN_MS; - if (viewDataProvider.isSkippedDate(startDate)) { + if (viewDataProvider.isDateSkipped(startDate)) { switch (true) { case isAllDay: result += DAY_HOURS; @@ -412,7 +410,7 @@ export const getSkippedHoursInRange = ( } } - if (viewDataProvider.isSkippedDate(endDate)) { + if (viewDataProvider.isDateSkipped(endDate)) { switch (true) { case isAllDay: result += DAY_HOURS; @@ -431,13 +429,6 @@ export const getSkippedHoursInRange = ( return result; }; -export const isDataOnWeekend = (date: Date): boolean => { - const day = date.getDay(); - return day === SATURDAY_INDEX || day === SUNDAY_INDEX; -}; - -export const getWeekendsCount = (days: number): number => 2 * Math.floor(days / 7); - export const extendGroupItemsForGroupingByDate = ( groupRenderItems: GroupRenderItem[][], columnCountPerGroup: number, diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts index 59c306211a45..744de1c99b8a 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/index.ts @@ -1,8 +1,10 @@ import { getThemeType } from '@ts/scheduler/r1/utils/themes'; import { + calculateEndViewDate, calculateRows, calculateStartViewDate, + getDateByIndex, } from './agenda'; import { calculateStartViewDate as dayCalculateStartViewDate, @@ -64,9 +66,7 @@ export { getValidCellDateForLocalTimeFormat, getVerticalGroupCountClass, getViewStartByOptions, - getWeekendsCount, isAppointmentTakesAllDay, - isDataOnWeekend, isDateAndTimeView, isDateInRange, isFirstCellInMonthWithIntervalCount, @@ -87,8 +87,10 @@ export { } from './format_weekday'; export const agendaUtils = { + calculateEndViewDate, calculateStartViewDate, calculateRows, + getDateByIndex, }; export const dayUtils = { diff --git a/packages/devextreme/js/__internal/scheduler/r1/utils/work_week.ts b/packages/devextreme/js/__internal/scheduler/r1/utils/work_week.ts index db381e2eb616..7067e4689238 100644 --- a/packages/devextreme/js/__internal/scheduler/r1/utils/work_week.ts +++ b/packages/devextreme/js/__internal/scheduler/r1/utils/work_week.ts @@ -1,19 +1,17 @@ import dateUtils from '@js/core/utils/date'; -import type { CalculateStartViewDate } from '../../types'; -import { getViewStartByOptions, isDataOnWeekend, setOptionHour } from './base'; +import { getFirstVisibleDate } from '../../utils/skipped_days'; +import { getViewStartByOptions, setOptionHour } from './base'; import { getValidStartDate } from './week'; -const MONDAY_INDEX = 1; -const DAYS_IN_WEEK = 7; - -export const calculateStartViewDate: CalculateStartViewDate = ( - currentDate, - startDayHour, - startDate, - intervalDuration, - firstDayOfWeek, -) => { +export const calculateStartViewDate = ( + currentDate: Date, + startDayHour: number, + startDate: Date, + intervalDuration: number, + firstDayOfWeek: number | undefined, + skippedDays: number[] = [0, 6], +): Date => { const viewStart = getViewStartByOptions( startDate, currentDate, @@ -21,13 +19,11 @@ export const calculateStartViewDate: CalculateStartViewDate = ( getValidStartDate(startDate, firstDayOfWeek), ); - const firstViewDate = dateUtils.getFirstWeekDate(viewStart, firstDayOfWeek); - if (isDataOnWeekend(firstViewDate)) { - const currentDay = firstViewDate.getDay(); - const distance = (MONDAY_INDEX + DAYS_IN_WEEK - currentDay) % 7; - - firstViewDate.setDate(firstViewDate.getDate() + distance); - } + const firstViewDate = getFirstVisibleDate( + dateUtils.getFirstWeekDate(viewStart, firstDayOfWeek), + skippedDays, + (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1), + ); return setOptionHour(firstViewDate, startDayHour); }; diff --git a/packages/devextreme/js/__internal/scheduler/scheduler_options_base_widget.ts b/packages/devextreme/js/__internal/scheduler/scheduler_options_base_widget.ts index 23883fcf5d21..50300301227e 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler_options_base_widget.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler_options_base_widget.ts @@ -8,6 +8,8 @@ import { DEFAULT_SCHEDULER_OPTIONS, DEFAULT_SCHEDULER_OPTIONS_RULES, } from './utils/options/constants'; +import { DEFAULT_VIEW_OPTIONS } from './utils/options/constants_view'; +import { resolveSkippedDays } from './utils/options/normalize_hidden_days'; import type { NormalizedView, SafeSchedulerOptions, SchedulerOptionsRule, View, } from './utils/options/types'; @@ -102,7 +104,22 @@ export class SchedulerOptionsBaseWidget extends Widget { const viewOptionValue = this.currentView?.[optionName as keyof View]; const optionValue = (viewOptionValue ?? this.option(optionName)) as SafeSchedulerOptions[K]; - return getViewOption(optionName, optionValue); + if (optionName === 'hiddenWeekDays') { + if (!this.currentView) { + return optionValue; + } + + return resolveSkippedDays( + this.currentView.hiddenWeekDays, + this.option('hiddenWeekDays'), + DEFAULT_VIEW_OPTIONS[this.currentView.type].skippedDays, + ) as SafeSchedulerOptions[K]; + } + + return getViewOption( + optionName, + optionValue, + ); } hasAgendaView(): boolean { diff --git a/packages/devextreme/js/__internal/scheduler/types.ts b/packages/devextreme/js/__internal/scheduler/types.ts index 2b4df4b92ec7..c6f84c390050 100644 --- a/packages/devextreme/js/__internal/scheduler/types.ts +++ b/packages/devextreme/js/__internal/scheduler/types.ts @@ -242,7 +242,7 @@ export interface ViewDataProviderType { getViewOptions: () => ViewOptions; setViewOptions: (options: ViewDataProviderOptions) => void; createGroupedDataMapProvider: () => void; - isSkippedDate: (date: Date) => boolean; + isDateSkipped: (date: Date) => boolean; getCellsByGroupIndexAndAllDay: (groupIndex: number, isAllDay: boolean) => ViewCellData[][]; getCellsBetween: (first: ViewCellData, last: ViewCellData) => ViewCellData[]; viewType: ViewType; diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts index b77adcfbdb06..c0aa187f34bb 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/constants.ts @@ -54,6 +54,7 @@ export const DEFAULT_SCHEDULER_OPTIONS: Properties = { maxAppointmentsPerCell: 'auto', selectedCellData: [], groupByDate: false, + hiddenWeekDays: undefined, onAppointmentRendered: undefined, onAppointmentClick: undefined, onAppointmentDblClick: undefined, diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.test.ts b/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.test.ts new file mode 100644 index 000000000000..4e844679978e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.test.ts @@ -0,0 +1,118 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import errors from '@js/ui/widget/ui.errors'; + +import { DEFAULT_VIEW_OPTIONS } from './constants_view'; +import { resolveSkippedDays } from './normalize_hidden_days'; +import type { RawViewType, ViewType } from './types'; +import { getCurrentView } from './utils'; + +describe('hiddenWeekDays', () => { + const getSkipped = ( + views: RawViewType[], + viewType: ViewType, + globalHiddenWeekDays?: number[], + ): number[] => { + const currentView = getCurrentView(viewType, views); + + return resolveSkippedDays( + currentView.hiddenWeekDays, + globalHiddenWeekDays, + DEFAULT_VIEW_OPTIONS[currentView.type].skippedDays, + ); + }; + + it('uses per-view hiddenWeekDays for week', () => { + expect(getSkipped([{ type: 'week', hiddenWeekDays: [3] }], 'week')).toEqual([3]); + }); + + it('lets workWeek override the default weekends with an empty list', () => { + expect(getSkipped([{ type: 'workWeek', hiddenWeekDays: [] }], 'workWeek')).toEqual([]); + }); + + it('lets workWeek override the default weekends with custom days', () => { + expect(getSkipped([{ type: 'workWeek', hiddenWeekDays: [3] }], 'workWeek')).toEqual([3]); + }); + + it('applies global hiddenWeekDays to workWeek', () => { + expect(getSkipped(['workWeek'], 'workWeek', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to timelineWorkWeek', () => { + expect(getSkipped(['timelineWorkWeek'], 'timelineWorkWeek', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to week', () => { + expect(getSkipped(['week'], 'week', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to month', () => { + expect(getSkipped(['month'], 'month', [0, 6])).toEqual([0, 6]); + }); + + it('applies global hiddenWeekDays to timelineWeek', () => { + expect(getSkipped(['timelineWeek'], 'timelineWeek', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to timelineMonth', () => { + expect(getSkipped(['timelineMonth'], 'timelineMonth', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to day', () => { + expect(getSkipped(['day'], 'day', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to timelineDay', () => { + expect(getSkipped(['timelineDay'], 'timelineDay', [3])).toEqual([3]); + }); + + it('applies global hiddenWeekDays to agenda', () => { + expect(getSkipped(['agenda'], 'agenda', [3])).toEqual([3]); + }); + + it('removes duplicates from per-view hiddenWeekDays', () => { + expect(getSkipped([{ type: 'week', hiddenWeekDays: [0, 0, 1, 1] }], 'week')).toEqual([0, 1]); + }); + + it('sorts per-view hiddenWeekDays', () => { + expect(getSkipped([{ type: 'week', hiddenWeekDays: [6, 0, 3] }], 'week')).toEqual([0, 3, 6]); + }); + + it('filters out invalid per-view hiddenWeekDays values', () => { + expect( + getSkipped([ + // @ts-expect-error intentionally pass invalid values to verify runtime filtering + { type: 'week', hiddenWeekDays: [7, -1, 1.5, 'x', null, 3] }, + ], 'week'), + ).toEqual([3]); + }); + + it('falls back to an empty list and logs error when all days are hidden', () => { + const logSpy = jest.spyOn(errors, 'log').mockImplementation(() => undefined); + try { + expect( + getSkipped([{ type: 'week', hiddenWeekDays: [0, 1, 2, 3, 4, 5, 6] }], 'week'), + ).toEqual([]); + expect(logSpy).toHaveBeenCalledWith('W1029'); + } finally { + logSpy.mockRestore(); + } + }); + + it('uses global hiddenWeekDays when week does not define its own value', () => { + expect(getSkipped([{ type: 'week' }], 'week', [3])).toEqual([3]); + }); + + it('keeps the per-view value for week when both global and per-view hiddenWeekDays are set', () => { + expect(getSkipped([{ type: 'week', hiddenWeekDays: [3] }], 'week', [0, 6])).toEqual([3]); + }); + + it('keeps the per-view value for workWeek when both global and per-view hiddenWeekDays are set', () => { + expect(getSkipped([{ type: 'workWeek', hiddenWeekDays: [3] }], 'workWeek', [0, 6])).toEqual([3]); + }); + + it('returns an empty list for week when hiddenWeekDays is not set anywhere', () => { + expect(getSkipped(['week'], 'week')).toEqual([]); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.ts b/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.ts new file mode 100644 index 000000000000..4f59b77749a3 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/options/normalize_hidden_days.ts @@ -0,0 +1,34 @@ +import errors from '@js/ui/widget/ui.errors'; + +import { isValidWeekday } from '../skipped_days'; + +const normalizeHiddenWeekDays = ( + days: unknown, +): number[] | undefined => { + if (!Array.isArray(days)) { + return undefined; + } + const valid = [...new Set(days)] + .filter(isValidWeekday) + .sort((a, b) => a - b); + if (valid.length >= 7) { + errors.log('W1029'); + return []; + } + return valid; +}; + +export const resolveSkippedDays = ( + perViewHiddenWeekDays: unknown, + globalHiddenWeekDays: number[] | undefined, + viewDefault: number[], +): number[] => { + const perView = normalizeHiddenWeekDays(perViewHiddenWeekDays); + if (perView !== undefined) { + return perView; + } + if (globalHiddenWeekDays !== undefined) { + return normalizeHiddenWeekDays(globalHiddenWeekDays) ?? []; + } + return viewDefault; +}; diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/utils.test.ts b/packages/devextreme/js/__internal/scheduler/utils/options/utils.test.ts index 5118a8c7ce1f..ab8c6ef8170a 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/utils.test.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/utils.test.ts @@ -2,18 +2,25 @@ import { describe, expect, it, } from '@jest/globals'; +import type { RawViewType } from './types'; import { - getCurrentView, getViewOption, getViews, parseCurrentDate, parseDateOption, + getCurrentView, + getViewOption, + getViews, + parseCurrentDate, + parseDateOption, } from './utils'; describe('views utils', () => { describe('getViews', () => { it('should filter view with incorrect name', () => { - expect(getViews(['unknown'] as any)).toEqual([]); + // @ts-expect-error intentionally pass an unsupported view name + expect(getViews(['unknown'])).toEqual([]); }); it('should filter view with incorrect type', () => { - expect(getViews([{ type: 'unknown' }] as any)).toEqual([]); + // @ts-expect-error intentionally pass an unsupported view type + expect(getViews([{ type: 'unknown' }])).toEqual([]); }); it('should not override view options by default options', () => { @@ -24,7 +31,7 @@ describe('views utils', () => { name: 'MyDay', groups: ['a', 'b'], }; - expect(getViews([input] as any)).toEqual([{ ...input, skippedDays: [] }]); + expect(getViews([input as RawViewType])).toEqual([{ ...input, skippedDays: [] }]); }); it.each([ @@ -106,7 +113,7 @@ describe('views utils', () => { type: 'agenda', }, }])('should return normalized $input.type view', ({ input, output }) => { - expect(getViews([input] as any)).toEqual([{ ...output, skippedDays: [] }]); + expect(getViews([input as RawViewType])).toEqual([{ ...output, skippedDays: [] }]); }); it.each([ @@ -126,7 +133,7 @@ describe('views utils', () => { }, }, ])('should return normalized $input.type view', ({ input, output }) => { - expect(getViews([input] as any)).toEqual([{ ...output, skippedDays: [0, 6] }]); + expect(getViews([input as RawViewType])).toEqual([{ ...output, skippedDays: [0, 6] }]); }); }); @@ -178,11 +185,16 @@ describe('views utils', () => { }); it('should return first known view if wrong current view requested', () => { - expect(getCurrentView('blabla', [{ - type: 'blabla', - name: 'blabla', - unknown: 'incorrect view', - } as any])).toEqual({ + expect(getCurrentView( + 'blabla', + [ + { + type: 'blabla', + name: 'blabla', + unknown: 'incorrect view', + } as unknown as RawViewType, + ], + )).toEqual({ groupOrientation: 'horizontal', intervalCount: 1, type: 'day', diff --git a/packages/devextreme/js/__internal/scheduler/utils/options/utils.ts b/packages/devextreme/js/__internal/scheduler/utils/options/utils.ts index 2a266891a5a9..7b3876df3cac 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/options/utils.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/options/utils.ts @@ -16,9 +16,11 @@ const normalizeView = (view: RawViewType): NormalizedView | undefined => (isObje ? extend({}, DEFAULT_VIEW_OPTIONS[view.type as string], view) as NormalizedView : DEFAULT_VIEW_OPTIONS[view]); -export const getViews = (views: RawViewType[]): NormalizedView[] => views +export const getViews = ( + views: RawViewType[], +): NormalizedView[] => views .filter(isKnownView) - .map(normalizeView) + .map((v) => normalizeView(v)) .filter(isExistedView); export function getCurrentView( diff --git a/packages/devextreme/js/__internal/scheduler/utils/skipped_days.ts b/packages/devextreme/js/__internal/scheduler/utils/skipped_days.ts new file mode 100644 index 000000000000..937ffa8f4526 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/skipped_days.ts @@ -0,0 +1,64 @@ +export const isValidWeekday = (value: unknown): value is number => ( + typeof value === 'number' + && Number.isInteger(value) + && value >= 0 + && value <= 6 +); + +export const isDateSkipped = (date: Date, skippedDays: number[]): boolean => ( + skippedDays.includes(date.getDay()) +); + +export const getVisibleDaysOfWeek = ( + firstDayOfWeek: number, + skippedDays: number[], +): number[] => { + const result: number[] = []; + for (let count = 0; count < 7; count += 1) { + const raw = firstDayOfWeek + count; + const dayOfWeek = ((raw % 7) + 7) % 7; + if (!skippedDays.includes(dayOfWeek)) { + result.push(dayOfWeek); + } + } + + return result; +}; + +export const getFirstVisibleDate = ( + start: Date, + skippedDays: number[], + nextDate: (date: Date) => Date, +): Date => { + if (skippedDays.length >= 7) { + return new Date(start); + } + + let date = new Date(start); + while (isDateSkipped(date, skippedDays)) { + date = nextDate(date); + } + return date; +}; + +export const getSkippedDaysCount = ( + start: Date, + dayCount: number, + skippedDays?: number[], +): number => { + if (dayCount <= 0 || !skippedDays || skippedDays.length === 0) { + return 0; + } + + const date = new Date(start); + let skippedCount = 0; + + for (let i = 0; i < dayCount; i += 1) { + if (isDateSkipped(date, skippedDays)) { + skippedCount += 1; + } + date.setDate(date.getDate() + 1); + } + + return skippedCount; +}; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts b/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts index 98483bff3457..59a57c045d4f 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/__mock__/scheduler.mock.ts @@ -23,7 +23,7 @@ export const getSchedulerMock = ({ isVirtualScrolling?: boolean; }): Scheduler => ({ timeZoneCalculator: mockTimeZoneCalculator, - currentView: { type, skippedDays: skippedDays ?? [] }, + currentView: { type, hiddenWeekDays: skippedDays }, getWorkSpace: () => ({ getDateRange: () => dateRange ?? [ new Date(2000, 0, 10, startDayHour), @@ -34,6 +34,7 @@ export const getSchedulerMock = ({ getViewOption: (name: string) => ({ startDayHour, endDayHour, + hiddenWeekDays: skippedDays ?? [], allDayPanelMode: 'allDay', cellDuration: 30, }[name]), diff --git a/packages/devextreme/js/__internal/scheduler/view_model/common/get_compare_options.ts b/packages/devextreme/js/__internal/scheduler/view_model/common/get_compare_options.ts index cc33db47c1ba..203db8f64802 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/common/get_compare_options.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/common/get_compare_options.ts @@ -12,7 +12,7 @@ export const getCompareOptions = ( endDayHour: schedulerStore.getViewOption('endDayHour'), min: timeZoneUtils.createUTCDateWithLocalOffset(dateRange[0]).getTime(), max: timeZoneUtils.createUTCDateWithLocalOffset(dateRange[1]).getTime(), - skippedDays: schedulerStore.currentView.skippedDays, + skippedDays: schedulerStore.getViewOption('hiddenWeekDays') as number[], }; return compareOptions; diff --git a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_minutes_cell_intervals.ts b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_minutes_cell_intervals.ts index fb2d55b50544..57ad9082a499 100644 --- a/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_minutes_cell_intervals.ts +++ b/packages/devextreme/js/__internal/scheduler/view_model/generate_view_model/options/get_minutes_cell_intervals.ts @@ -12,7 +12,10 @@ interface Options { const filterBySkippedDays = ( intervals: T[], skippedDays: number[], -): T[] => intervals.filter((item) => !skippedDays.includes(new Date(item.min).getUTCDay())); +): T[] => intervals.filter((item) => { + const weekday = new Date(item.min).getUTCDay(); + return !skippedDays.includes(weekday); +}); export const getMinutesCellIntervals = ({ intervals, diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_agenda.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_agenda.ts index a886699802ec..969eeda71374 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_agenda.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_agenda.ts @@ -152,7 +152,10 @@ class SchedulerAgenda extends WorkSpace { } protected override renderView() { - this.startViewDate = agendaUtils.calculateStartViewDate(this.option('currentDate') as any, this.option('startDayHour') as any); + this.startViewDate = agendaUtils.calculateStartViewDate( + this.option('currentDate'), + this.option('startDayHour'), + ); this.rows = []; } @@ -441,10 +444,10 @@ class SchedulerAgenda extends WorkSpace { } private getTimePanelStartDate(rowIndex) { - const current = new Date(this.option('currentDate') as any); - const cellDate = new Date(current.setDate(current.getDate() + rowIndex)); - - return cellDate; + return agendaUtils.getDateByIndex( + this.getStartViewDate(), + rowIndex, + ); } private getRowHeight(rowSize) { @@ -485,14 +488,11 @@ class SchedulerAgenda extends WorkSpace { } getEndViewDate() { - const currentDate = new Date(this.option('currentDate') as any); - const agendaDuration: any = this.option('agendaDuration'); - - currentDate.setHours(this.option('endDayHour') as any); - - const result = currentDate.setDate(currentDate.getDate() + agendaDuration - 1) - 60000; - - return new Date(result); + return agendaUtils.calculateEndViewDate( + this.getStartViewDate(), + this.option('endDayHour') as any, + this.option('agendaDuration') as any, + ); } getEndViewDateByEndDayHour() { diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline.ts index e66d081086ff..cb5a4bca1c3b 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline.ts @@ -18,6 +18,7 @@ import { import tableCreatorModule from '../m_table_creator'; import timezoneUtils from '../m_utils_time_zone'; import HorizontalShader from '../shaders/current_time_shader_horizontal'; +import { getFirstVisibleDate } from '../utils/skipped_days'; import SchedulerWorkSpace from './m_work_space_indicator'; const { tableCreator } = tableCreatorModule; @@ -108,7 +109,21 @@ class SchedulerTimeline extends SchedulerWorkSpace { } protected incrementDate(date) { - date.setDate(date.getDate() + 1); + const skippedDays = this.option('skippedDays') ?? []; + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + 1); + + const nextVisibleDate = getFirstVisibleDate( + nextDate, + skippedDays, + (currentDate) => { + const result = new Date(currentDate); + result.setDate(result.getDate() + 1); + return result; + }, + ); + + date.setTime(nextVisibleDate.getTime()); } getIndicationCellCount() { @@ -133,6 +148,10 @@ class SchedulerTimeline extends SchedulerWorkSpace { protected calculateDurationInCells(timeDiff) { const today = this.getToday(); const differenceInDays = Math.floor(timeDiff / toMs('day')); + const skippedDaysCount = this.getSkippedDaysCount( + this.getIndicationFirstViewDate(), + differenceInDays, + ); let duration = (timeDiff - differenceInDays * toMs('day') - (this.option('startDayHour') as any) * toMs('hour')) / this.getCellDuration(); if (today.getHours() > (this.option('endDayHour') as any)) { @@ -142,7 +161,7 @@ class SchedulerTimeline extends SchedulerWorkSpace { if (duration < 0) { duration = 0; } - return differenceInDays * this.getCellCountInDay() + duration; + return (differenceInDays - skippedDaysCount) * this.getCellCountInDay() + duration; } getIndicationWidth() { @@ -223,7 +242,8 @@ class SchedulerTimeline extends SchedulerWorkSpace { const fullDays = Math.floor(fullInterval / toMs('day')); const tailDuration = fullInterval - (fullDays * toMs('day')); let tailDelta = 0; - const cellCount = this.getCellCountInDay() * (fullDays - this.getWeekendsCount(fullDays)); + const skippedDaysCount = this.getSkippedDaysCount(firstViewDate, fullDays); + const cellCount = this.getCellCountInDay() * (fullDays - skippedDaysCount); const gapBeforeAppt = apptStart - dateUtils.trimTime(new Date(currentDate)).getTime(); let result = cellCount * (this.option('hoursInterval') as any) * toMs('hour'); @@ -253,11 +273,6 @@ class SchedulerTimeline extends SchedulerWorkSpace { return result; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected override getWeekendsCount(argument?: any) { - return 0; - } - getAllDayContainer() { return null; } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_week.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_week.ts index 0f38e0b3cdd5..96851eb816b5 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_week.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_week.ts @@ -15,10 +15,6 @@ export default class SchedulerTimelineWeek extends SchedulerTimeline { protected override needRenderWeekHeader() { return true; } - - protected override incrementDate(date) { - date.setDate(date.getDate() + 1); - } } registerComponent('dxSchedulerTimelineWeek', SchedulerTimelineWeek as any); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_work_week.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_work_week.ts index f4fe25697191..155d5868a52f 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_work_week.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_timeline_work_week.ts @@ -1,35 +1,16 @@ import registerComponent from '@js/core/component_registrator'; -import { - getWeekendsCount, -} from '@ts/scheduler/r1/utils/index'; import { VIEWS } from '../utils/options/constants_view'; import SchedulerTimelineWeek from './m_timeline_week'; const TIMELINE_CLASS = 'dx-scheduler-timeline-work-week'; -const LAST_DAY_WEEK_INDEX = 5; class SchedulerTimelineWorkWeek extends SchedulerTimelineWeek { get type() { return VIEWS.TIMELINE_WORK_WEEK; } - constructor(...args: any[]) { - // @ts-expect-error - super(...args); - - this.getWeekendsCount = getWeekendsCount; - } - protected override getElementClass() { return TIMELINE_CLASS; } - - protected override incrementDate(date) { - const day = date.getDay(); - if (day === LAST_DAY_WEEK_INDEX) { - date.setDate(date.getDate() + 2); - } - super.incrementDate(date); - } } registerComponent('dxSchedulerTimelineWorkWeek', SchedulerTimelineWorkWeek as any); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts index ce529c625c80..2a0008d79057 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space.ts @@ -80,6 +80,7 @@ import { import { getLeafGroupValues } from '../utils/resource_manager/group_utils'; import type { ResourceManager } from '../utils/resource_manager/resource_manager'; import type { GroupValues, RawGroupValues } from '../utils/resource_manager/types'; +import { getSkippedDaysCount as countSkippedDays } from '../utils/skipped_days'; import { getAllDayHeight, getCellHeight, @@ -204,6 +205,7 @@ type WorkspaceOptionsInternal = Omit & { hoursInterval: number; startDayHour: number; endDayHour: number; + skippedDays?: number[]; }; class SchedulerWorkSpace extends Widget { private viewDataProviderValue: any; @@ -908,6 +910,7 @@ class SchedulerWorkSpace extends Widget { startDate: this.option('startDate'), firstDayOfWeek: this.option('firstDayOfWeek'), showCurrentTimeIndicator: this.option('showCurrentTimeIndicator'), + skippedDays: this.option('skippedDays'), ...this.virtualScrollingDispatcher.getRenderState(), }; @@ -1302,9 +1305,14 @@ class SchedulerWorkSpace extends Widget { return { startDayHour: this.option('startDayHour'), endDayHour: this.option('endDayHour'), + hoursInterval: this.option('hoursInterval'), interval: this.viewDataProvider.viewDataGenerator?.getInterval(this.option('hoursInterval')), + intervalCount: this.option('intervalCount'), startViewDate: this.getStartViewDate(), firstDayOfWeek: this.firstDayOfWeek(), + skippedDays: this.option('skippedDays'), + viewOffset: 0, + viewType: this.type, }; } @@ -1312,26 +1320,29 @@ class SchedulerWorkSpace extends Widget { protected getIntervalBetween(currentDate, allDay) { const firstViewDate = this.getStartViewDate(); - const startDayTime = (this.option('startDayHour') as any) * HOUR_MS; + const startDayTime = this.option('startDayHour') * HOUR_MS; const timeZoneOffset = dateUtils.getTimezonesDifference(firstViewDate, currentDate); const fullInterval = currentDate.getTime() - firstViewDate.getTime() - timeZoneOffset; const days = this.getDaysOfInterval(fullInterval, startDayTime); - const weekendsCount = this.getWeekendsCount(days); - let result = (days - weekendsCount) * DAY_MS; + const skippedDaysCount = this.getSkippedDaysCount(firstViewDate, days); + let result = (days - skippedDaysCount) * DAY_MS; if (!allDay) { const { hiddenInterval } = this.viewDataProvider; const visibleDayDuration = this.getVisibleDayDuration(); - result = fullInterval - days * hiddenInterval - weekendsCount * visibleDayDuration; + result = fullInterval - days * hiddenInterval - skippedDaysCount * visibleDayDuration; } return result; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected getWeekendsCount(argument?: any) { - return 0; + protected getSkippedDaysCount(startDate: Date, days: number) { + return countSkippedDays( + startDate, + days, + this.option('skippedDays'), + ); } private getDaysOfInterval(fullInterval, startDayTime) { @@ -2291,6 +2302,7 @@ class SchedulerWorkSpace extends Widget { groupOrientation: 'horizontal', selectedCellData: [], groupByDate: false, + skippedDays: undefined, scrolling: { mode: 'standard', }, diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_indicator.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_indicator.ts index ce2e3250776e..361e5ab59195 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_indicator.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_indicator.ts @@ -127,9 +127,12 @@ class SchedulerWorkSpaceIndicator extends SchedulerWorkSpace { const viewStartTime = this.getStartViewDate().getTime(); let timeDiff = today.getTime() - viewStartTime; - if (this.option('type') === 'workWeek') { - const weekendDays = this.getWeekendsCount(Math.round(timeDiff / toMs('day'))) * toMs('day'); - timeDiff -= weekendDays; + if (((this.option('skippedDays')) ?? []).length > 0) { + const skippedDaysDuration = this.getSkippedDaysCount( + this.getStartViewDate(), + Math.round(timeDiff / toMs('day')), + ) * toMs('day'); + timeDiff -= skippedDaysDuration; } return Math.ceil((timeDiff + 1) / toMs('day')); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_work_week.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_work_week.ts index c0b43a1bc5aa..57c27f341df9 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_work_week.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_work_space_work_week.ts @@ -1,7 +1,4 @@ import registerComponent from '@js/core/component_registrator'; -import { - getWeekendsCount, -} from '@ts/scheduler/r1/utils/index'; import { VIEWS } from '../utils/options/constants_view'; import SchedulerWorkSpaceWeek from './m_work_space_week'; @@ -10,13 +7,6 @@ const WORK_WEEK_CLASS = 'dx-scheduler-work-space-work-week'; class SchedulerWorkSpaceWorkWeek extends SchedulerWorkSpaceWeek { get type() { return VIEWS.WORK_WEEK; } - constructor(...args: any[]) { - // @ts-expect-error - super(...args); - - this.getWeekendsCount = getWeekendsCount; - } - protected override getElementClass() { return WORK_WEEK_CLASS; } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_types.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_types.ts index 6662e5e7a9d6..5f4509955bdb 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_types.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_types.ts @@ -16,6 +16,7 @@ interface CommonOptions extends CountGenerationConfig { viewOffset: number; hoursInterval: number; viewType: ViewType; + skippedDays?: number[]; cellCount: number; isProvideVirtualCellsWidth: boolean; isGenerateTimePanelData?: boolean; diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.test.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.test.ts new file mode 100644 index 000000000000..ce69888eb7f7 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { ViewType } from '../../types'; +import { ViewDataGeneratorDay } from './m_view_data_generator_day'; +import { ViewDataGeneratorMonth } from './m_view_data_generator_month'; +import { ViewDataGeneratorTimelineMonth } from './m_view_data_generator_timeline_month'; +import { ViewDataGeneratorWeek } from './m_view_data_generator_week'; +import { ViewDataGeneratorWorkWeek } from './m_view_data_generator_work_week'; + +describe('ViewDataGenerator hiddenWeekDays support', () => { + describe('daysInInterval getter', () => { + it('week view: 6 with skippedDays [3]', () => { + const gen = new ViewDataGeneratorWeek('week' as ViewType); + gen.skippedDays = [3]; + expect(gen.daysInInterval).toBe(6); + }); + + it('workWeek view: 5 with default skippedDays [0,6]', () => { + const gen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + gen.skippedDays = [0, 6]; + expect(gen.daysInInterval).toBe(5); + }); + + it('workWeek view: 7 with empty skippedDays override', () => { + const gen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + gen.skippedDays = []; + expect(gen.daysInInterval).toBe(7); + }); + }); + + describe('getVisibleDaysOfWeek', () => { + it('returns all 7 days when skippedDays is empty, rotated by firstDayOfWeek', () => { + const gen = new ViewDataGeneratorWeek('week' as ViewType); + gen.skippedDays = []; + expect(gen.getVisibleDaysOfWeek(0)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(gen.getVisibleDaysOfWeek(1)).toEqual([1, 2, 3, 4, 5, 6, 0]); + }); + + it('skips hidden days, preserving visible-day order from firstDayOfWeek', () => { + const gen = new ViewDataGeneratorWeek('week' as ViewType); + gen.skippedDays = [0, 6]; + expect(gen.getVisibleDaysOfWeek(0)).toEqual([1, 2, 3, 4, 5]); + expect(gen.getVisibleDaysOfWeek(1)).toEqual([1, 2, 3, 4, 5]); + }); + + it('skips a single mid-week day', () => { + const gen = new ViewDataGeneratorWeek('week' as ViewType); + gen.skippedDays = [3]; + expect(gen.getVisibleDaysOfWeek(0)).toEqual([0, 1, 2, 4, 5, 6]); + expect(gen.getVisibleDaysOfWeek(1)).toEqual([1, 2, 4, 5, 6, 0]); + }); + }); + + describe('getVisibleDayOffset for week-style layout', () => { + const gen = new ViewDataGeneratorWeek('week' as ViewType); + + const callGetVisibleDayOffset = ( + g: ViewDataGeneratorWeek, + rowIndex: number, + columnIndex: number, + firstDayOfWeek: number, + cellCountInDay: number, + ): number => (g as unknown as { + getVisibleDayOffset: (r: number, c: number, firstDay: number, cellCount: number) => number; + }).getVisibleDayOffset(rowIndex, columnIndex, firstDayOfWeek, cellCountInDay); + + it('zero offset for empty skippedDays', () => { + gen.skippedDays = []; + expect(callGetVisibleDayOffset(gen, 0, 0, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 5, 0, 1)).toBe(0); + }); + + it('week with [0,6], firstDayOfWeek=1 (Mon): col 0..4 → 0 offset, col 5 → +2', () => { + gen.skippedDays = [0, 6]; + [0, 1, 2, 3, 4].forEach((col) => { + expect(callGetVisibleDayOffset(gen, 0, col, 1, 1)).toBe(0); + }); + expect(callGetVisibleDayOffset(gen, 0, 5, 1, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 0, 9, 1, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 0, 10, 1, 1)).toBe(4); + }); + + it('week with [3] (skip Wed), firstDayOfWeek=0 (Sun): col 3 → +1 to skip Wed', () => { + gen.skippedDays = [3]; + expect(callGetVisibleDayOffset(gen, 0, 0, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 1, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 2, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 3, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 0, 4, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 0, 5, 0, 1)).toBe(1); + }); + + it('week with [1,3,5] (skip Mon, Wed, Fri), firstDayOfWeek=0', () => { + gen.skippedDays = [1, 3, 5]; + expect(callGetVisibleDayOffset(gen, 0, 0, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 1, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 0, 2, 0, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 0, 3, 0, 1)).toBe(3); + expect(callGetVisibleDayOffset(gen, 0, 4, 0, 1)).toBe(3); + }); + + it('timelineWorkWeek with multiple cells in day uses day index', () => { + const timelineWorkWeekGen = new ViewDataGeneratorWorkWeek('timelineWorkWeek' as ViewType); + timelineWorkWeekGen.skippedDays = [0, 6]; + + const timelineWorkWeek = timelineWorkWeekGen as unknown as ViewDataGeneratorWeek; + + // 2 cells per day, first visible week day is Monday (firstDayOfWeek=1) + // Both cells of the first day must have the same offset. + expect(callGetVisibleDayOffset(timelineWorkWeek, 0, 0, 1, 2)).toBe(0); + expect(callGetVisibleDayOffset(timelineWorkWeek, 0, 1, 1, 2)).toBe(0); + // The first cell of next visible day still has zero offset. + expect(callGetVisibleDayOffset(timelineWorkWeek, 0, 2, 1, 2)).toBe(0); + // After 5 visible days (10 cells), the next day jumps over weekend (+2 days). + expect(callGetVisibleDayOffset(timelineWorkWeek, 0, 10, 1, 2)).toBe(2); + }); + + it('vertical workWeek layout uses column index as day index', () => { + const workWeekGen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + workWeekGen.skippedDays = [0, 6]; + + const verticalWorkWeek = workWeekGen as unknown as ViewDataGeneratorWeek; + + expect(callGetVisibleDayOffset(verticalWorkWeek, 0, 0, 3, 24)).toBe(0); + expect(callGetVisibleDayOffset(verticalWorkWeek, 0, 1, 3, 24)).toBe(0); + expect(callGetVisibleDayOffset(verticalWorkWeek, 0, 2, 3, 24)).toBe(0); + expect(callGetVisibleDayOffset(verticalWorkWeek, 0, 3, 3, 24)).toBe(2); + expect(callGetVisibleDayOffset(verticalWorkWeek, 0, 4, 3, 24)).toBe(2); + }); + }); + + describe('getVisibleDayOffset for month-style layout', () => { + const gen = new ViewDataGeneratorMonth('month' as ViewType); + + const callGetVisibleDayOffset = ( + g: ViewDataGeneratorMonth, + rowIndex: number, + columnIndex: number, + firstDayOfWeek: number, + cellCountInDay: number, + ): number => (g as unknown as { + getVisibleDayOffset: (r: number, c: number, firstDay: number, cellCount: number) => number; + }).getVisibleDayOffset(rowIndex, columnIndex, firstDayOfWeek, cellCountInDay); + + it('returns 0 for empty skippedDays', () => { + gen.skippedDays = []; + expect(callGetVisibleDayOffset(gen, 0, 0, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 3, 5, 0, 1)).toBe(0); + }); + + it('month with [0,6], firstDayOfWeek=1: row=1 col=0 → +2 (jumps over Sat+Sun)', () => { + gen.skippedDays = [0, 6]; + expect(callGetVisibleDayOffset(gen, 0, 0, 1, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 4, 1, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 1, 0, 1, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 1, 4, 1, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 2, 0, 1, 1)).toBe(4); + }); + + it('month with [3] (skip Wed), firstDayOfWeek=0: visible days = Sun,Mon,Tue,Thu,Fri,Sat', () => { + gen.skippedDays = [3]; + expect(callGetVisibleDayOffset(gen, 0, 0, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 2, 0, 1)).toBe(0); + expect(callGetVisibleDayOffset(gen, 0, 3, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 0, 5, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 1, 0, 0, 1)).toBe(1); + expect(callGetVisibleDayOffset(gen, 1, 3, 0, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 1, 5, 0, 1)).toBe(2); + expect(callGetVisibleDayOffset(gen, 2, 0, 0, 1)).toBe(2); + }); + }); + + describe('Month view getCellCount honors skippedDays', () => { + it('returns 5 with skippedDays [0, 6]', () => { + const gen = new ViewDataGeneratorMonth('month' as ViewType); + gen.skippedDays = [0, 6]; + expect(gen.getCellCount()).toBe(5); + }); + + it('returns 6 with skippedDays [3]', () => { + const gen = new ViewDataGeneratorMonth('month' as ViewType); + gen.skippedDays = [3]; + expect(gen.getCellCount()).toBe(6); + }); + }); + + describe('TimelineMonth hiddenWeekDays support', () => { + it('maps next visible column to Monday when start is Friday and weekends are skipped', () => { + const gen = new ViewDataGeneratorTimelineMonth('timelineMonth' as ViewType); + gen.skippedDays = [0, 6]; + + const startViewDate = new Date(2026, 4, 1, 0, 0); // Friday + const options = { + startViewDate, + startDayHour: 0, + endDayHour: 24, + hoursInterval: 1, + interval: 24 * 60 * 60 * 1000, + firstDayOfWeek: 1, // Monday + intervalCount: 1, + viewOffset: 0, + currentDate: new Date(2026, 4, 15), + viewType: 'timelineMonth' as ViewType, + }; + + const date = gen.getDateByCellIndices(options, 0, 1); + expect(date.getDay()).toBe(1); + expect(date.getDate()).toBe(4); + }); + }); + + describe('WorkWeek hiddenWeekDays support', () => { + it('uses week start when skippedDays override is empty', () => { + const gen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + gen.skippedDays = []; + + const startViewDate = gen.getStartViewDate({ + currentDate: new Date(2026, 3, 1), // Wednesday + startDayHour: 0, + startDate: undefined, + intervalCount: 1, + firstDayOfWeek: 0, // Sunday + }); + + expect(startViewDate.getDay()).toBe(0); + expect(startViewDate.getDate()).toBe(29); + }); + + it('uses first visible day of week for custom skippedDays', () => { + const gen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + gen.skippedDays = [1, 2]; + + const startViewDate = gen.getStartViewDate({ + currentDate: new Date(2026, 3, 1), // Wednesday + startDayHour: 0, + startDate: undefined, + intervalCount: 1, + firstDayOfWeek: 0, // Sunday + }); + + expect(startViewDate.getDay()).toBe(0); + expect(startViewDate.getDate()).toBe(29); + }); + + it('keeps first visible column on Monday when startViewDate is already Monday', () => { + const gen = new ViewDataGeneratorWorkWeek('workWeek' as ViewType); + gen.skippedDays = [0, 6]; + + const options = { + startViewDate: new Date(2026, 2, 30, 0, 0), // Monday + startDayHour: 0, + endDayHour: 24, + hoursInterval: 1, + interval: 24 * 60 * 60 * 1000, + firstDayOfWeek: 0, // Sunday + intervalCount: 1, + viewOffset: 0, + currentDate: new Date(2026, 3, 1), + viewType: 'workWeek' as ViewType, + }; + + const date = gen.getDateByCellIndices(options, 0, 0); + expect(date.getDay()).toBe(1); + expect(date.getDate()).toBe(30); + }); + }); + + describe('Day hiddenWeekDays support', () => { + it('shifts startViewDate to the next visible day', () => { + const gen = new ViewDataGeneratorDay('day' as ViewType); + gen.skippedDays = [0, 6]; + + const startViewDate = gen.getStartViewDate({ + currentDate: new Date(2026, 3, 11), // Saturday + startDayHour: 0, + startDate: undefined, + intervalCount: 1, + }); + + expect(startViewDate.getDay()).toBe(1); + expect(startViewDate.getDate()).toBe(13); + }); + + it('maps multi-day timeline columns to visible days only', () => { + const gen = new ViewDataGeneratorDay('timelineDay' as ViewType); + gen.skippedDays = [0, 6]; + + const options = { + startViewDate: new Date(2026, 3, 10, 0, 0), // Friday + startDayHour: 0, + endDayHour: 24, + hoursInterval: 24, + interval: 24 * 60 * 60 * 1000, + firstDayOfWeek: 0, + intervalCount: 3, + viewOffset: 0, + currentDate: new Date(2026, 3, 10), + viewType: 'timelineDay' as ViewType, + }; + + const Monday = gen.getDateByCellIndices(options, 0, 1); + const Tuesday = gen.getDateByCellIndices(options, 0, 2); + + expect(Monday.getDay()).toBe(1); + expect(Monday.getDate()).toBe(13); + expect(Tuesday.getDay()).toBe(2); + expect(Tuesday.getDate()).toBe(14); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.ts index d7626023448d..9c5161a4060a 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator.ts @@ -18,6 +18,10 @@ import { import type { ViewDataMap, ViewType } from '../../types'; import { VIEWS } from '../../utils/options/constants_view'; import { getAllGroupValues } from '../../utils/resource_manager/group_utils'; +import { + getVisibleDaysOfWeek, + isDateSkipped, +} from '../../utils/skipped_days'; import type { ViewCellDataSimple, ViewCellGeneratedData, @@ -28,14 +32,27 @@ import type { const toMs = dateUtils.dateToMilliseconds; export class ViewDataGenerator { - readonly daysInInterval: number = 1; - protected tableAllDay = false; public hiddenInterval = 0; + public skippedDays: number[] = []; + constructor(public readonly viewType: ViewType) {} + get daysInInterval(): number { + const isWeekLikeView = [ + VIEWS.WEEK, + VIEWS.TIMELINE_WEEK, + VIEWS.WORK_WEEK, + VIEWS.TIMELINE_WORK_WEEK, + ].includes(this.viewType); + + return isWeekLikeView + ? 7 - this.skippedDays.length + : 1; + } + public isWorkWeekView(): boolean { return [ VIEWS.WORK_WEEK, @@ -43,11 +60,54 @@ export class ViewDataGenerator { ].includes(this.viewType); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isSkippedDate(date: any) { + protected usesMonthDayLayout(): boolean { return false; } + public getVisibleDaysOfWeek(firstDayOfWeek: number): number[] { + return getVisibleDaysOfWeek(firstDayOfWeek, this.skippedDays); + } + + protected getSkippedDaysAnchorDay( + firstDayOfWeekOption: number | undefined, + startViewDate: Date, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return this.getFirstDayOfWeek(firstDayOfWeekOption) ?? 0; + } + + private getVisibleDayOffset( + rowIndex: number, + columnIndex: number, + anchorDay: number, + cellCountInDay: number, + ): number { + const rotated = this.getVisibleDaysOfWeek(anchorDay); + const visibleCount = rotated.length; + if (visibleCount === 0) { + return 0; + } + if (this.usesMonthDayLayout()) { + const targetDayOfWeek = rotated[columnIndex]; + const naiveDayOffset = rowIndex * visibleCount + columnIndex; + const actualDayOffset = rowIndex * 7 + + ((targetDayOfWeek - anchorDay + 7) % 7); + return actualDayOffset - naiveDayOffset; + } + const dayIndex = isHorizontalView(this.viewType) + ? Math.floor(columnIndex / cellCountInDay) + : columnIndex; + const week = Math.floor(dayIndex / visibleCount); + const idxInWeek = dayIndex % visibleCount; + const targetDayOfWeek = rotated[idxInWeek]; + const naiveDayOffset = dayIndex; + const actualDayOffset = week * 7 + ((targetDayOfWeek - anchorDay + 7) % 7); + return actualDayOffset - naiveDayOffset; + } + + public isDateSkipped(date: Date): boolean { + return isDateSkipped(date, this.skippedDays); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars protected calculateStartViewDate(options: any): Date { return new Date(); @@ -74,6 +134,7 @@ export class ViewDataGenerator { hoursInterval, } = options; + this.skippedDays = options.skippedDays ?? this.skippedDays; this.setVisibilityDates(options); this.setHiddenInterval(startDayHour, endDayHour, hoursInterval); @@ -502,7 +563,6 @@ export class ViewDataGenerator { hoursInterval, interval, firstDayOfWeek, - intervalCount, viewOffset, } = options; const cellCountInDay = this.getCellCountInDay(startDayHour, endDayHour, hoursInterval); @@ -512,13 +572,17 @@ export class ViewDataGenerator { const cellIndex = this.calculateCellIndex(rowIndex, columnIndex, rowCountBase, columnCountBase); const millisecondsOffset = this.getMillisecondsOffset(cellIndex, interval, cellCountInDay); - const offsetByCount = this.isWorkWeekView() - ? this.getTimeOffsetByColumnIndex( + let offsetByCount: number; + if (this.skippedDays.length > 0) { + offsetByCount = this.getVisibleDayOffset( + rowIndex, columnIndex, - this.getFirstDayOfWeek(firstDayOfWeek), - columnCountBase, - intervalCount, - ) : 0; + this.getSkippedDaysAnchorDay(firstDayOfWeek, startViewDate), + cellCountInDay, + ) * toMs('day'); + } else { + offsetByCount = 0; + } const isStartViewDateDuringDST = startViewDate.getHours() !== Math.floor(startDayHour); let startViewDateTime = startViewDate.getTime(); @@ -559,14 +623,6 @@ export class ViewDataGenerator { return interval * cellIndex + realHiddenInterval; } - getTimeOffsetByColumnIndex(columnIndex, firstDayOfWeek, columnCount, intervalCount) { - const firstDayOfWeekDiff = Math.max(0, firstDayOfWeek - 1); - const columnsInWeek = columnCount / intervalCount; - const weekendCount = Math.floor((columnIndex + firstDayOfWeekDiff) / columnsInWeek); - - return weekendCount * 2 * toMs('day'); - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars public calculateEndDate(startDate: Date, interval: number, endDayHour?: any): Date { return this.getCellEndDate(startDate, { interval }); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_day.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_day.ts index f29deb8ec768..038519991dce 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_day.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_day.ts @@ -1,13 +1,32 @@ import { dayUtils } from '../../r1/utils/index'; +import { getFirstVisibleDate } from '../../utils/skipped_days'; import { ViewDataGenerator } from './m_view_data_generator'; export class ViewDataGeneratorDay extends ViewDataGenerator { - protected calculateStartViewDate(options) { - return dayUtils.calculateStartViewDate( + // eslint-disable-next-line class-methods-use-this + protected override getSkippedDaysAnchorDay( + firstDayOfWeekOption: number | undefined, + startViewDate: Date, + ): number { + return startViewDate.getDay(); + } + + protected override calculateStartViewDate(options: any): Date { + const startViewDate = dayUtils.calculateStartViewDate( options.currentDate, options.startDayHour, options.startDate, this._getIntervalDuration(options.intervalCount), ); + + return getFirstVisibleDate( + startViewDate, + options.skippedDays ?? this.skippedDays, + (date) => { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + 1); + return nextDate; + }, + ); } } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_month.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_month.ts index 519bdf076052..9f979cfa05b1 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_month.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_month.ts @@ -90,7 +90,11 @@ export class ViewDataGeneratorMonth extends ViewDataGenerator { } getCellCount() { - return DAYS_IN_WEEK; + return DAYS_IN_WEEK - this.skippedDays.length; + } + + protected usesMonthDayLayout(): boolean { + return true; } getRowCount(options) { diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_timeline_month.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_timeline_month.ts index dd0f45ed6e7e..56a45bd5fd49 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_timeline_month.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_timeline_month.ts @@ -2,12 +2,20 @@ import dateUtils from '@js/core/utils/date'; import { setOptionHour, timelineMonthUtils } from '@ts/scheduler/r1/utils/index'; import timezoneUtils from '../../m_utils_time_zone'; +import type { CountGenerationConfig } from '../../types'; import { ViewDataGenerator } from './m_view_data_generator'; const toMs = dateUtils.dateToMilliseconds; export class ViewDataGeneratorTimelineMonth extends ViewDataGenerator { - calculateEndDate(startDate, interval, endDayHour) { + protected override getSkippedDaysAnchorDay( + firstDayOfWeekOption: number | undefined, + startViewDate: Date, + ): number { + return startViewDate.getDay(); + } + + calculateEndDate(startDate: Date, interval: number, endDayHour: number): Date { return setOptionHour(startDate, endDayHour); } @@ -15,7 +23,11 @@ export class ViewDataGeneratorTimelineMonth extends ViewDataGenerator { return toMs('day'); } - protected calculateStartViewDate(options: any) { + getCellCountInDay(): number { + return 1; + } + + protected calculateStartViewDate(options: any): Date { return timelineMonthUtils.calculateStartViewDate( options.currentDate, options.startDayHour, @@ -24,19 +36,26 @@ export class ViewDataGeneratorTimelineMonth extends ViewDataGenerator { ); } - getCellCount(options) { + getCellCount(options: CountGenerationConfig): number { const { intervalCount } = options; const currentDate = new Date(options.currentDate); let cellCount = 0; - for (let i = 1; i <= intervalCount; i++) { - cellCount += new Date(currentDate.getFullYear(), currentDate.getMonth() + i, 0).getDate(); + for (let i = 1; i <= intervalCount; i += 1) { + const monthDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + i, 0); + const daysInMonth = monthDate.getDate(); + for (let day = 1; day <= daysInMonth; day += 1) { + const date = new Date(monthDate.getFullYear(), monthDate.getMonth(), day); + if (!this.isDateSkipped(date)) { + cellCount += 1; + } + } } return cellCount; } - setHiddenInterval() { + setHiddenInterval(): void { this.hiddenInterval = 0; } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_week.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_week.ts index c62a758cc001..eb69dc885954 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_week.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_week.ts @@ -2,8 +2,6 @@ import { weekUtils } from '../../r1/utils/index'; import { ViewDataGenerator } from './m_view_data_generator'; export class ViewDataGeneratorWeek extends ViewDataGenerator { - readonly daysInInterval: number = 7; - _getIntervalDuration(intervalCount) { return weekUtils.getIntervalDuration(intervalCount); } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_work_week.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_work_week.ts index ecf2465f32b7..0be766ea5383 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_work_week.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_generator_work_week.ts @@ -1,24 +1,31 @@ -import { isDataOnWeekend, workWeekUtils } from '../../r1/utils/index'; +import { workWeekUtils } from '../../r1/utils/index'; import { ViewDataGeneratorWeek } from './m_view_data_generator_week'; export class ViewDataGeneratorWorkWeek extends ViewDataGeneratorWeek { - readonly daysInInterval = 5; + public skippedDays: number[] = [0, 6]; - isSkippedDate(date) { - return isDataOnWeekend(date); - } - - protected calculateStartViewDate(options) { + protected override calculateStartViewDate(options: any): Date { return workWeekUtils.calculateStartViewDate( options.currentDate, options.startDayHour, options.startDate, this._getIntervalDuration(options.intervalCount), this.getFirstDayOfWeek(options.firstDayOfWeek), + options.skippedDays ?? this.skippedDays, ); } - getFirstDayOfWeek(firstDayOfWeekOption) { - return firstDayOfWeekOption || 0; + // eslint-disable-next-line class-methods-use-this + public override getFirstDayOfWeek(firstDayOfWeekOption: number | undefined): number { + return firstDayOfWeekOption ?? 0; + } + + protected override getSkippedDaysAnchorDay( + firstDayOfWeekOption: number | undefined, + startViewDate: Date, + ): number { + return this.skippedDays.length > 0 + ? startViewDate.getDay() + : this.getFirstDayOfWeek(firstDayOfWeekOption); } } diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts index 6b0458ebe787..1189212f074b 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/view_model/m_view_data_provider.ts @@ -65,7 +65,7 @@ export default class ViewDataProvider { get hiddenInterval() { return this.viewDataGenerator.hiddenInterval; } - isSkippedDate(date: Date): boolean { return this.viewDataGenerator.isSkippedDate(date); } + isDateSkipped(date: Date): boolean { return this.viewDataGenerator.isDateSkipped(date); } update(options: ViewDataProviderOptions, isGenerateNewViewData: boolean): void { this.viewDataGenerator = getViewDataGeneratorByViewType(options.viewType); diff --git a/packages/devextreme/js/ui/scheduler.d.ts b/packages/devextreme/js/ui/scheduler.d.ts index df45266e11c3..8e1a71df45bf 100644 --- a/packages/devextreme/js/ui/scheduler.d.ts +++ b/packages/devextreme/js/ui/scheduler.d.ts @@ -81,6 +81,8 @@ export type SnapToCellsMode = 'always' | 'auto' | 'never'; export type RecurrenceEditMode = 'dialog' | 'occurrence' | 'series'; /** @public */ export type AppointmentFormIconsShowMode = 'both' | 'main' | 'recurrence' | 'none'; +/** @public */ +export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; /** * @docid @@ -685,6 +687,12 @@ export interface dxSchedulerOptions extends WidgetOptions { * @public */ firstDayOfWeek?: FirstDayOfWeek | undefined; + /** + * @docid + * @default undefined + * @public + */ + hiddenWeekDays?: Array; /** * @docid * @default true &for(desktop) @@ -1087,6 +1095,11 @@ export interface dxSchedulerOptions extends WidgetOptions { * @default undefined */ firstDayOfWeek?: FirstDayOfWeek | undefined; + /** + * @docid + * @default undefined + */ + hiddenWeekDays?: Array; /** * @docid * @default false diff --git a/packages/devextreme/js/ui/scheduler_types.d.ts b/packages/devextreme/js/ui/scheduler_types.d.ts index a213dd60cb1a..d6571eb09c40 100644 --- a/packages/devextreme/js/ui/scheduler_types.d.ts +++ b/packages/devextreme/js/ui/scheduler_types.d.ts @@ -7,6 +7,7 @@ export { SnapToCellsMode, RecurrenceEditMode, AppointmentFormIconsShowMode, + DayOfWeek, AppointmentFormProperties, ViewType, SchedulerScrollToAlign, diff --git a/packages/devextreme/js/ui/widget/ui.errors.js b/packages/devextreme/js/ui/widget/ui.errors.js index e340779e1e15..033a93901892 100644 --- a/packages/devextreme/js/ui/widget/ui.errors.js +++ b/packages/devextreme/js/ui/widget/ui.errors.js @@ -397,4 +397,8 @@ export default errorUtils(errors.ERROR_MESSAGES, { * @name ErrorsUIWidgets.W1028 */ W1028: 'Nested/banded columns do not support the following properties: {0}.', + /** + * @name ErrorsUIWidgets.W1029 + */ + W1029: '\'hiddenWeekDays\' must leave at least one weekday visible.', }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js index eeef176f2bff..a7ed83460ef6 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.scheduler/view_data_provider.tests.js @@ -960,7 +960,7 @@ module('View Data Provider', { }); }); - module('isSkippedDate', () => { + module('isDateSkipped', () => { test('it should return correct value for the weekend', async function(assert) { [ { viewType: 'day', expected: false }, @@ -973,18 +973,18 @@ module('View Data Provider', { { viewType: 'timelineMonth', expected: false }, ].forEach(({ viewType, expected }) => { const viewDataProvider = new ViewDataProvider(viewType); - const result = viewDataProvider.isSkippedDate(new Date(2021, 8, 4)); + const result = viewDataProvider.isDateSkipped(new Date(2021, 8, 4)); - assert.equal(result, expected, `isSkippedDate is correct for the ${viewType} view type`); + assert.equal(result, expected, `isDateSkipped is correct for the ${viewType} view type`); }); }); test('it should return correct value for the week day', async function(assert) { supportedViews.forEach((viewType) => { const viewDataProvider = new ViewDataProvider(viewType); - const result = viewDataProvider.isSkippedDate(new Date(2021, 8, 3)); + const result = viewDataProvider.isDateSkipped(new Date(2021, 8, 3)); - assert.notOk(result, `isSkippedDate is correct for the ${viewType} view type`); + assert.notOk(result, `isDateSkipped is correct for the ${viewType} view type`); }); }); }); diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 51da85b9e775..37480da4c36c 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -26243,6 +26243,7 @@ declare module DevExpress.ui { readonly endDate: Date; readonly text: string; }; + export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; /** * [descr:_ui_scheduler_DisposingEvent] */ @@ -26578,6 +26579,10 @@ declare module DevExpress.ui { * [descr:dxSchedulerOptions.firstDayOfWeek] */ firstDayOfWeek?: DevExpress.common.FirstDayOfWeek | undefined; + /** + * [descr:dxSchedulerOptions.hiddenWeekDays] + */ + hiddenWeekDays?: Array; /** * [descr:dxSchedulerOptions.focusStateEnabled] */ @@ -26909,6 +26914,10 @@ declare module DevExpress.ui { * [descr:dxSchedulerOptions.views.firstDayOfWeek] */ firstDayOfWeek?: DevExpress.common.FirstDayOfWeek | undefined; + /** + * [descr:dxSchedulerOptions.views.hiddenWeekDays] + */ + hiddenWeekDays?: Array; /** * [descr:dxSchedulerOptions.views.groupByDate] */