Skip to content

Commit 222c8bb

Browse files
[scheduler] Allow to generate the visible days in the view config (#20419)
1 parent 309895b commit 222c8bb

File tree

32 files changed

+521
-690
lines changed

32 files changed

+521
-690
lines changed

packages/x-scheduler-headless/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@
1919
"./constants": "./src/constants/index.ts",
2020
"./event-calendar-provider": "./src/event-calendar-provider/index.ts",
2121
"./event-calendar-selectors": "./src/event-calendar-selectors/index.ts",
22+
"./get-day-list": "./src/get-day-list/index.ts",
2223
"./models": "./src/models/index.ts",
2324
"./process-date": "./src/process-date/index.ts",
2425
"./process-event": "./src/process-event/index.ts",
2526
"./scheduler-selectors": "./src/scheduler-selectors/index.ts",
27+
"./sort-event-occurrences": "./src/sort-event-occurrences/index.ts",
2628
"./standalone-event": "./src/standalone-event/index.ts",
2729
"./timeline": "./src/timeline/index.ts",
2830
"./timeline-provider": "./src/timeline-provider/index.ts",
2931
"./timeline-selectors": "./src/timeline-selectors/index.ts",
3032
"./use-adapter": "./src/use-adapter/index.ts",
31-
"./use-agenda-event-occurrences-grouped-by-day": "./src/use-agenda-event-occurrences-grouped-by-day/index.ts",
32-
"./use-day-list": "./src/use-day-list/index.ts",
3333
"./use-event-calendar": "./src/use-event-calendar/index.ts",
3434
"./use-event-calendar-store-context": "./src/use-event-calendar-store-context/index.ts",
3535
"./use-event-calendar-view": "./src/use-event-calendar-view/index.ts",
@@ -40,8 +40,7 @@
4040
"./use-month-list": "./src/use-month-list/index.ts",
4141
"./use-scheduler-store-context": "./src/use-scheduler-store-context/index.ts",
4242
"./use-timeline": "./src/use-timeline/index.ts",
43-
"./use-timeline-store-context": "./src/use-timeline-store-context/index.ts",
44-
"./use-week-list": "./src/use-week-list/index.ts"
43+
"./use-timeline-store-context": "./src/use-timeline-store-context/index.ts"
4544
},
4645
"sideEffects": false,
4746
"publishConfig": {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { adapter, EventBuilder, getEventCalendarStateFromParameters } from 'test/utils/scheduler';
2+
import { processDate } from '../process-date';
3+
import { eventCalendarAgendaSelectors } from './eventCalendarAgendaSelectors';
4+
import { AGENDA_VIEW_DAYS_AMOUNT } from '../constants';
5+
6+
describe('eventCalendarEventSelectors', () => {
7+
describe('visibleDays', () => {
8+
it('should return exactly AGENDA_VIEW_DAYS_AMOUNT days and fills occurrences with [] when there are no events and showEmptyDaysInAgenda=true', () => {
9+
const state = getEventCalendarStateFromParameters({
10+
events: [],
11+
visibleDate: adapter.date('2024-01-01', 'default'),
12+
defaultPreferences: {
13+
showWeekends: true,
14+
showEmptyDaysInAgenda: true,
15+
},
16+
});
17+
const visibleDays = eventCalendarAgendaSelectors.visibleDays(state);
18+
19+
expect(visibleDays).to.have.length(AGENDA_VIEW_DAYS_AMOUNT);
20+
});
21+
22+
it('should extend forward until it fills AGENDA_VIEW_DAYS_AMOUNT days that contain events when showEmptyDaysInAgenda=false', () => {
23+
const events = [
24+
EventBuilder.new().fullDay('2025-01-01').build(),
25+
EventBuilder.new().fullDay('2025-01-03').build(),
26+
EventBuilder.new().fullDay('2025-01-05').build(),
27+
EventBuilder.new().fullDay('2025-01-08').build(),
28+
EventBuilder.new().fullDay('2025-01-09').build(),
29+
EventBuilder.new().fullDay('2025-01-10').build(),
30+
EventBuilder.new().fullDay('2025-01-11').build(),
31+
EventBuilder.new().fullDay('2025-01-13').build(),
32+
EventBuilder.new().fullDay('2025-01-14').build(),
33+
EventBuilder.new().fullDay('2025-01-16').build(),
34+
EventBuilder.new().fullDay('2025-01-18').build(),
35+
EventBuilder.new().fullDay('2025-01-20').build(),
36+
EventBuilder.new().fullDay('2025-01-22').build(),
37+
EventBuilder.new().fullDay('2025-01-24').build(),
38+
];
39+
40+
const state = getEventCalendarStateFromParameters({
41+
events,
42+
visibleDate: adapter.date('2025-01-01', 'default'),
43+
defaultPreferences: {
44+
showWeekends: true,
45+
showEmptyDaysInAgenda: false,
46+
},
47+
});
48+
49+
const visibleDays = eventCalendarAgendaSelectors.visibleDays(state);
50+
51+
expect(visibleDays).to.deep.equal([
52+
processDate(adapter.date('2025-01-01', 'default'), adapter),
53+
processDate(adapter.date('2025-01-03', 'default'), adapter),
54+
processDate(adapter.date('2025-01-05', 'default'), adapter),
55+
processDate(adapter.date('2025-01-08', 'default'), adapter),
56+
processDate(adapter.date('2025-01-09', 'default'), adapter),
57+
processDate(adapter.date('2025-01-10', 'default'), adapter),
58+
processDate(adapter.date('2025-01-11', 'default'), adapter),
59+
processDate(adapter.date('2025-01-13', 'default'), adapter),
60+
processDate(adapter.date('2025-01-14', 'default'), adapter),
61+
processDate(adapter.date('2025-01-16', 'default'), adapter),
62+
processDate(adapter.date('2025-01-18', 'default'), adapter),
63+
processDate(adapter.date('2025-01-20', 'default'), adapter),
64+
]);
65+
});
66+
67+
it('should respect showWeekends preference when building the day list', () => {
68+
const events = [
69+
EventBuilder.new().fullDay('2025-10-03').build(), // Fri
70+
EventBuilder.new().fullDay('2025-10-04').build(), // Sat
71+
EventBuilder.new().fullDay('2025-10-05').build(), // Sun
72+
EventBuilder.new().fullDay('2025-10-06').build(), // Mon
73+
EventBuilder.new().fullDay('2025-10-07').build(), // Tue
74+
EventBuilder.new().fullDay('2025-10-08').build(), // Wed
75+
EventBuilder.new().fullDay('2025-10-09').build(), // Thu
76+
EventBuilder.new().fullDay('2025-10-10').build(), // Fri
77+
EventBuilder.new().fullDay('2025-10-11').build(), // Sat
78+
EventBuilder.new().fullDay('2025-10-12').build(), // Sun
79+
EventBuilder.new().fullDay('2025-10-13').build(), // Mon
80+
EventBuilder.new().fullDay('2025-10-14').build(), // Tue
81+
EventBuilder.new().fullDay('2025-10-15').build(), // Wed
82+
EventBuilder.new().fullDay('2025-10-16').build(), // Thu
83+
EventBuilder.new().fullDay('2025-10-17').build(), // Fri
84+
EventBuilder.new().fullDay('2025-10-18').build(), // Sat
85+
EventBuilder.new().fullDay('2025-10-19').build(), // Sun
86+
EventBuilder.new().fullDay('2025-10-20').build(), // Mon
87+
];
88+
89+
const state = getEventCalendarStateFromParameters({
90+
events,
91+
visibleDate: adapter.date('2025-10-03', 'default'), // Friday
92+
defaultPreferences: {
93+
showWeekends: false,
94+
showEmptyDaysInAgenda: false,
95+
},
96+
});
97+
98+
const visibleDays = eventCalendarAgendaSelectors.visibleDays(state);
99+
100+
expect(visibleDays).to.deep.equal([
101+
processDate(adapter.date('2025-10-03', 'default'), adapter),
102+
processDate(adapter.date('2025-10-06', 'default'), adapter),
103+
processDate(adapter.date('2025-10-07', 'default'), adapter),
104+
processDate(adapter.date('2025-10-08', 'default'), adapter),
105+
processDate(adapter.date('2025-10-09', 'default'), adapter),
106+
processDate(adapter.date('2025-10-10', 'default'), adapter),
107+
processDate(adapter.date('2025-10-13', 'default'), adapter),
108+
processDate(adapter.date('2025-10-14', 'default'), adapter),
109+
processDate(adapter.date('2025-10-15', 'default'), adapter),
110+
processDate(adapter.date('2025-10-16', 'default'), adapter),
111+
processDate(adapter.date('2025-10-17', 'default'), adapter),
112+
processDate(adapter.date('2025-10-20', 'default'), adapter),
113+
]);
114+
});
115+
});
116+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { createSelectorMemoized } from '@base-ui-components/utils/store';
2+
import { EventCalendarState as State } from '../use-event-calendar';
3+
import {
4+
schedulerEventSelectors,
5+
schedulerOtherSelectors,
6+
schedulerResourceSelectors,
7+
} from '../scheduler-selectors';
8+
import { eventCalendarPreferenceSelectors } from './eventCalendarPreferenceSelectors';
9+
import { innerGetEventOccurrencesGroupedByDay } from '../use-event-occurrences-grouped-by-day';
10+
import { SchedulerProcessedDate } from '../models';
11+
import { diffIn } from '../use-adapter';
12+
import { AGENDA_MAX_HORIZON_DAYS, AGENDA_VIEW_DAYS_AMOUNT } from '../constants';
13+
import { getDayList } from '../get-day-list';
14+
15+
export const eventCalendarAgendaSelectors = {
16+
visibleDays: createSelectorMemoized(
17+
(state: State) => state.adapter,
18+
schedulerOtherSelectors.visibleDate,
19+
eventCalendarPreferenceSelectors.showWeekends,
20+
eventCalendarPreferenceSelectors.showEmptyDaysInAgenda,
21+
schedulerEventSelectors.processedEventList,
22+
schedulerResourceSelectors.visibleMap,
23+
schedulerResourceSelectors.resourceParentIdLookup,
24+
(
25+
adapter,
26+
visibleDate,
27+
showWeekends,
28+
showEmptyDaysInAgenda,
29+
events,
30+
visibleResources,
31+
resourceParentIds,
32+
) => {
33+
const amount = AGENDA_VIEW_DAYS_AMOUNT;
34+
35+
// 1) First chunk of days
36+
let accumulatedDays = getDayList({
37+
adapter,
38+
start: visibleDate,
39+
end: adapter.addDays(visibleDate, amount - 1),
40+
excludeWeekends: !showWeekends,
41+
});
42+
43+
// Compute occurrences for the current accumulated range
44+
let occurrenceMap = innerGetEventOccurrencesGroupedByDay({
45+
adapter,
46+
days: accumulatedDays,
47+
events,
48+
visibleResources,
49+
resourceParentIds,
50+
});
51+
52+
const hasEvents = (day: SchedulerProcessedDate) =>
53+
(occurrenceMap.get(day.key)?.length ?? 0) > 0;
54+
55+
// 2) If we show empty days, just return the amount days
56+
if (showEmptyDaysInAgenda) {
57+
return accumulatedDays;
58+
}
59+
60+
// 3) If we hide empty days, keep extending forward in blocks until we fill `amount` days with events
61+
let daysWithEvents = accumulatedDays.filter(hasEvents).slice(0, amount);
62+
63+
while (daysWithEvents.length < amount) {
64+
// Stop if the calendar span already reaches the horizon
65+
const first = accumulatedDays[0]?.value;
66+
const last = accumulatedDays[accumulatedDays.length - 1]?.value;
67+
68+
if (first && last) {
69+
const spanDays =
70+
diffIn(adapter, adapter.startOfDay(last), adapter.startOfDay(first), 'days') + 1;
71+
72+
// Hard stop to avoid scanning too far into the future
73+
if (spanDays >= AGENDA_MAX_HORIZON_DAYS) {
74+
break;
75+
}
76+
}
77+
78+
// Extend forward by one more chunk and recompute occurrences over the accumulated range
79+
const nextStart = adapter.addDays(last ?? visibleDate, 1);
80+
81+
const more = getDayList({
82+
adapter,
83+
start: nextStart,
84+
end: adapter.addDays(nextStart, amount),
85+
excludeWeekends: !showWeekends,
86+
});
87+
88+
accumulatedDays = accumulatedDays.concat(more);
89+
90+
occurrenceMap = innerGetEventOccurrencesGroupedByDay({
91+
adapter,
92+
days: accumulatedDays,
93+
events,
94+
visibleResources,
95+
resourceParentIds,
96+
});
97+
98+
daysWithEvents = accumulatedDays.filter(hasEvents).slice(0, amount);
99+
}
100+
101+
return daysWithEvents;
102+
},
103+
),
104+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './eventCalendarPreferenceSelectors';
22
export * from './eventCalendarViewSelectors';
33
export * from './eventCalendarOccurrencePlaceholderSelectors';
4+
export * from './eventCalendarAgendaSelectors';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { adapter, DEFAULT_TESTING_VISIBLE_DATE } from 'test/utils/scheduler';
2+
import { getDayList } from './getDayList';
3+
import { isWeekend } from '../use-adapter';
4+
import { processDate } from '../process-date';
5+
6+
describe('getDayList', () => {
7+
it('should throw an error when amount is a non positive number', () => {
8+
expect(() =>
9+
getDayList({
10+
adapter,
11+
start: DEFAULT_TESTING_VISIBLE_DATE,
12+
end: adapter.addDays(DEFAULT_TESTING_VISIBLE_DATE, -5),
13+
}),
14+
).to.throw(/must be a day after/);
15+
});
16+
17+
it('should return one day when the start and end dates are equal', () => {
18+
const days = getDayList({
19+
adapter,
20+
start: DEFAULT_TESTING_VISIBLE_DATE,
21+
end: DEFAULT_TESTING_VISIBLE_DATE,
22+
});
23+
24+
const expectedDays = [processDate(adapter.startOfDay(DEFAULT_TESTING_VISIBLE_DATE), adapter)];
25+
26+
expect(days).to.deep.equal(expectedDays);
27+
});
28+
29+
it('should return consecutive days', () => {
30+
const days = getDayList({
31+
adapter,
32+
start: DEFAULT_TESTING_VISIBLE_DATE,
33+
end: adapter.addDays(DEFAULT_TESTING_VISIBLE_DATE, 3),
34+
});
35+
36+
const start = adapter.startOfDay(DEFAULT_TESTING_VISIBLE_DATE);
37+
const expectedDays = Array.from({ length: 4 }, (_, i) =>
38+
processDate(adapter.addDays(start, i), adapter),
39+
);
40+
41+
expect(days).to.deep.equal(expectedDays);
42+
});
43+
44+
it('should exclude weekends when excludeWeekends=true', () => {
45+
const days = getDayList({
46+
adapter,
47+
start: adapter.startOfWeek(DEFAULT_TESTING_VISIBLE_DATE),
48+
end: adapter.endOfWeek(DEFAULT_TESTING_VISIBLE_DATE),
49+
excludeWeekends: true,
50+
});
51+
52+
const start = adapter.startOfWeek(DEFAULT_TESTING_VISIBLE_DATE);
53+
const expectedDays = Array.from({ length: 7 }, (_, i) =>
54+
processDate(adapter.addDays(start, i), adapter),
55+
).filter((day) => !isWeekend(adapter, day.value));
56+
57+
expect(days).to.deep.equal(expectedDays);
58+
});
59+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { SchedulerProcessedDate, SchedulerValidDate } from '../models';
2+
import { isWeekend } from '../use-adapter/useAdapter';
3+
import { processDate } from '../process-date';
4+
import { TemporalAdapter } from '../base-ui-copy/types';
5+
6+
export function getDayList(parameters: GetDaytListParameters): GetDaytListReturnValue {
7+
const { adapter, start: rawStart, end: rawEnd, excludeWeekends } = parameters;
8+
9+
const start = adapter.startOfDay(rawStart);
10+
const end = adapter.endOfDay(rawEnd);
11+
12+
if (process.env.NODE_ENV !== 'production') {
13+
if (adapter.isBefore(adapter.startOfDay(end), adapter.startOfDay(start))) {
14+
throw new Error(`getDayList: The 'end' parameter must be a day after the 'start' parameter.`);
15+
}
16+
}
17+
18+
let current = start;
19+
let currentDayNumber = adapter.getDayOfWeek(current);
20+
const days: SchedulerValidDate[] = [];
21+
22+
while (adapter.isBefore(current, end)) {
23+
if (!excludeWeekends || !isWeekend(adapter, current)) {
24+
days.push(current);
25+
}
26+
27+
const prevDayNumber = currentDayNumber;
28+
current = adapter.addDays(current, 1);
29+
currentDayNumber = adapter.getDayOfWeek(current);
30+
31+
// If there is a TZ change at midnight, adding 1 day may only increase the date by 23 hours to 11pm
32+
// To fix, bump the date into the next day (add 12 hours) and then revert to the start of the day
33+
// See https://github.com/moment/moment/issues/4743#issuecomment-811306874 for context.
34+
if (prevDayNumber === currentDayNumber) {
35+
current = adapter.startOfDay(adapter.addHours(current, 12));
36+
}
37+
}
38+
39+
return days.map((day) => processDate(day, adapter));
40+
}
41+
42+
export interface GetDaytListParameters {
43+
/**
44+
* The adapter used to manipulate the date.
45+
*/
46+
adapter: TemporalAdapter;
47+
/**
48+
* The start of the range to generate the day list from.
49+
*/
50+
start: SchedulerValidDate;
51+
/**
52+
* The end of the range to generate the day list from.
53+
*/
54+
end: SchedulerValidDate;
55+
/**
56+
* Whether to exclude weekends (Saturday and Sunday) from the returned days.
57+
* @default false
58+
*/
59+
excludeWeekends?: boolean;
60+
}
61+
62+
export type GetDaytListReturnValue = SchedulerProcessedDate[];
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './getDayList';

0 commit comments

Comments
 (0)