@happychef/algorithm 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/.github/workflows/ci-cd.yml +80 -80
  3. package/BRANCH_PROTECTION_SETUP.md +167 -167
  4. package/CHANGELOG.md +8 -8
  5. package/README.md +144 -144
  6. package/RESERVERINGEN_GIDS.md +986 -986
  7. package/__tests__/crossMidnight.test.js +63 -0
  8. package/__tests__/crossMidnightTimeblocks.test.js +312 -0
  9. package/__tests__/edgeCases.test.js +271 -0
  10. package/__tests__/filters.test.js +276 -276
  11. package/__tests__/isDateAvailable.test.js +179 -175
  12. package/__tests__/isTimeAvailable.test.js +174 -168
  13. package/__tests__/restaurantData.test.js +422 -422
  14. package/__tests__/tableHelpers.test.js +247 -247
  15. package/assignTables.js +506 -444
  16. package/changes/2025/December/PR2___change.md +14 -14
  17. package/changes/2025/December/PR3_add__change.md +20 -20
  18. package/changes/2025/December/PR4___.md +15 -15
  19. package/changes/2025/December/PR5___.md +15 -15
  20. package/changes/2025/December/PR6__del_.md +17 -17
  21. package/changes/2025/December/PR7_add__change.md +21 -21
  22. package/changes/2026/February/PR15_add__change.md +21 -21
  23. package/changes/2026/February/PR16_add__.md +20 -0
  24. package/changes/2026/February/PR16_add_getDateClosingReasons.md +31 -31
  25. package/changes/2026/January/PR10_add__change.md +21 -21
  26. package/changes/2026/January/PR11_add__change.md +19 -19
  27. package/changes/2026/January/PR12_add__.md +21 -21
  28. package/changes/2026/January/PR13_add__change.md +20 -20
  29. package/changes/2026/January/PR14_add__change.md +19 -19
  30. package/changes/2026/January/PR8_add__change.md +38 -38
  31. package/changes/2026/January/PR9_add__change.md +19 -19
  32. package/dateHelpers.js +31 -0
  33. package/filters/maxArrivalsFilter.js +114 -114
  34. package/filters/maxGroupsFilter.js +221 -221
  35. package/filters/timeFilter.js +89 -89
  36. package/getAvailableTimeblocks.js +158 -158
  37. package/getDateClosingReasons.js +193 -193
  38. package/grouping.js +162 -162
  39. package/index.js +48 -43
  40. package/isDateAvailable.js +80 -80
  41. package/isDateAvailableWithTableCheck.js +172 -172
  42. package/isTimeAvailable.js +26 -26
  43. package/jest.config.js +23 -23
  44. package/package.json +27 -27
  45. package/processing/dailyGuestCounts.js +73 -73
  46. package/processing/mealTypeCount.js +133 -133
  47. package/processing/timeblocksAvailable.js +344 -182
  48. package/reservation_data/counter.js +82 -75
  49. package/restaurant_data/exceptions.js +150 -150
  50. package/restaurant_data/openinghours.js +142 -142
  51. package/simulateTableAssignment.js +833 -726
  52. package/tableHelpers.js +209 -209
  53. package/tables/time/parseTime.js +19 -19
  54. package/tables/time/shifts.js +7 -7
  55. package/tables/utils/calculateDistance.js +13 -13
  56. package/tables/utils/isTableFreeForAllSlots.js +14 -14
  57. package/tables/utils/isTemporaryTableValid.js +39 -39
  58. package/test/test_counter.js +194 -194
  59. package/test/test_dailyCount.js +81 -81
  60. package/test/test_datesAvailable.js +106 -106
  61. package/test/test_exceptions.js +172 -172
  62. package/test/test_isDateAvailable.js +330 -330
  63. package/test/test_mealTypeCount.js +54 -54
  64. package/test/test_timesAvailable.js +88 -88
  65. package/test-detailed-filter.js +100 -100
  66. package/test-lunch-debug.js +110 -110
  67. package/test-max-arrivals-filter.js +79 -79
  68. package/test-meal-stop-fix.js +147 -147
  69. package/test-meal-stop-simple.js +93 -93
  70. package/test-timezone-debug.js +47 -47
  71. package/test.js +336 -336
@@ -0,0 +1,63 @@
1
+ // __tests__/crossMidnight.test.js
2
+ // Tests for cross-midnight reservation support (consecutive slots across days)
3
+
4
+ const { getNextDateStr, getPrevDateStr } = require('../dateHelpers');
5
+
6
+ // --- dateHelpers tests ---
7
+ describe('dateHelpers', () => {
8
+ test('getNextDateStr returns next day', () => {
9
+ expect(getNextDateStr('2025-02-20')).toBe('2025-02-21');
10
+ expect(getNextDateStr('2025-02-28')).toBe('2025-03-01');
11
+ expect(getNextDateStr('2025-12-31')).toBe('2026-01-01');
12
+ });
13
+
14
+ test('getPrevDateStr returns previous day', () => {
15
+ expect(getPrevDateStr('2025-02-21')).toBe('2025-02-20');
16
+ expect(getPrevDateStr('2025-03-01')).toBe('2025-02-28');
17
+ expect(getPrevDateStr('2026-01-01')).toBe('2025-12-31');
18
+ });
19
+ });
20
+
21
+ // --- counter.js: getGuestCountAtHour with previous-day spillover ---
22
+ const { getGuestCountAtHour } = require('../reservation_data/counter');
23
+
24
+ describe('getGuestCountAtHour - previous-day spillover', () => {
25
+ const data = {
26
+ 'general-settings': {
27
+ duurReservatie: '60',
28
+ intervalReservatie: '15',
29
+ },
30
+ };
31
+
32
+ test('counts same-day reservation guests at overlapping hour', () => {
33
+ const reservations = [
34
+ { date: '2025-02-20', time: '22:00', guests: 4, duration: 120 },
35
+ ];
36
+ const count = getGuestCountAtHour(data, reservations, '22:30', '2025-02-20');
37
+ expect(count).toBe(4);
38
+ });
39
+
40
+ test('counts previous-day spillover guests after midnight', () => {
41
+ const reservations = [
42
+ { date: '2025-02-19', time: '23:00', guests: 6, duration: 180 },
43
+ ];
44
+ const count = getGuestCountAtHour(data, reservations, '01:00', '2025-02-20');
45
+ expect(count).toBe(6);
46
+ });
47
+
48
+ test('does not count previous-day reservation that ended before target time', () => {
49
+ const reservations = [
50
+ { date: '2025-02-19', time: '23:00', guests: 6, duration: 120 },
51
+ ];
52
+ const count = getGuestCountAtHour(data, reservations, '01:30', '2025-02-20');
53
+ expect(count).toBe(0);
54
+ });
55
+
56
+ test('does not count previous-day reservation that does not cross midnight', () => {
57
+ const reservations = [
58
+ { date: '2025-02-19', time: '20:00', guests: 4, duration: 60 },
59
+ ];
60
+ const count = getGuestCountAtHour(data, reservations, '00:30', '2025-02-20');
61
+ expect(count).toBe(0);
62
+ });
63
+ });
@@ -0,0 +1,312 @@
1
+ // __tests__/crossMidnightTimeblocks.test.js
2
+ // Tests for timeblocksAvailable cross-midnight slot generation
3
+
4
+ jest.mock('../restaurant_data/exceptions', () => ({
5
+ getDataByDateAndMealWithExceptions: jest.fn(),
6
+ getDataByDateAndTimeWithExceptions: jest.fn(),
7
+ }));
8
+
9
+ jest.mock('../restaurant_data/openinghours', () => ({
10
+ getMealTypeByTime: jest.fn(),
11
+ parseTime: jest.fn((timeStr) => {
12
+ const [h, m] = timeStr.split(':').map(Number);
13
+ return h * 60 + m;
14
+ }),
15
+ daysOfWeekEnglish: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
16
+ }));
17
+
18
+ jest.mock('../processing/dailyGuestCounts', () => ({
19
+ getDailyGuestCounts: jest.fn(),
20
+ }));
21
+
22
+ jest.mock('../processing/mealTypeCount', () => ({
23
+ getMealTypesWithShifts: jest.fn(),
24
+ }));
25
+
26
+ jest.mock('../reservation_data/counter', () => ({
27
+ getGuestCountAtHour: jest.fn(),
28
+ }));
29
+
30
+ const { timeblocksAvailable } = require('../processing/timeblocksAvailable');
31
+ const { getDataByDateAndMealWithExceptions } = require('../restaurant_data/exceptions');
32
+ const { getDailyGuestCounts } = require('../processing/dailyGuestCounts');
33
+ const { getMealTypesWithShifts } = require('../processing/mealTypeCount');
34
+ const { getGuestCountAtHour: mockGetGuestCountAtHour } = require('../reservation_data/counter');
35
+ const { getMealTypeByTime } = require('../restaurant_data/openinghours');
36
+
37
+ describe('timeblocksAvailable - cross-midnight', () => {
38
+ beforeEach(() => {
39
+ jest.clearAllMocks();
40
+ });
41
+
42
+ // Per-day format: hoursOpenedPastMidnight in openinghours-dinner.schemeSettings
43
+ function setupBowlingData(hoursOpenedPastMidnight = 3) {
44
+ const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
45
+ const schemeSettings = {};
46
+ for (const day of days) {
47
+ schemeSettings[day] = { hoursOpenedPastMidnight };
48
+ }
49
+
50
+ const data = {
51
+ 'general-settings': {
52
+ duurReservatie: '60',
53
+ intervalReservatie: '30',
54
+ },
55
+ 'openinghours-dinner': {
56
+ schemeSettings,
57
+ },
58
+ };
59
+
60
+ // Bowling restaurant: dinner 16:00-24:00
61
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, dateStr, mealType) => {
62
+ if (mealType === 'dinner') {
63
+ return {
64
+ enabled: true,
65
+ startTime: '16:00',
66
+ endTime: '24:00',
67
+ maxCapacity: 40,
68
+ };
69
+ }
70
+ return null;
71
+ });
72
+
73
+ getMealTypesWithShifts.mockReturnValue([]);
74
+
75
+ return data;
76
+ }
77
+
78
+ // Per-day format with different values per day
79
+ function setupPerDayData(perDayValues) {
80
+ const schemeSettings = {};
81
+ for (const [day, val] of Object.entries(perDayValues)) {
82
+ schemeSettings[day] = { hoursOpenedPastMidnight: val };
83
+ }
84
+
85
+ const data = {
86
+ 'general-settings': {
87
+ duurReservatie: '60',
88
+ intervalReservatie: '30',
89
+ },
90
+ 'openinghours-dinner': {
91
+ schemeSettings,
92
+ },
93
+ };
94
+
95
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, dateStr, mealType) => {
96
+ if (mealType === 'dinner') {
97
+ return {
98
+ enabled: true,
99
+ startTime: '16:00',
100
+ endTime: '24:00',
101
+ maxCapacity: 40,
102
+ };
103
+ }
104
+ return null;
105
+ });
106
+
107
+ getMealTypesWithShifts.mockReturnValue([]);
108
+
109
+ return data;
110
+ }
111
+
112
+ function makeDinnerGuestCounts() {
113
+ const guestCounts = {};
114
+ for (let t = 960; t <= 1410; t += 30) {
115
+ const h = Math.floor(t / 60);
116
+ const m = t % 60;
117
+ const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
118
+ guestCounts[key] = 38; // 40 - 2 = 38 seats available
119
+ }
120
+ return guestCounts;
121
+ }
122
+
123
+ test('generates late-evening slots when hoursOpenedPastMidnight is set', () => {
124
+ const data = setupBowlingData(3);
125
+
126
+ getDailyGuestCounts.mockReturnValue({
127
+ guestCounts: makeDinnerGuestCounts(),
128
+ shiftsInfo: [],
129
+ });
130
+
131
+ mockGetGuestCountAtHour.mockReturnValue(0);
132
+
133
+ // 2025-02-20 is Thursday
134
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, 180);
135
+
136
+ // 23:00 + 180min = 02:00, within 3h limit
137
+ expect(result['23:00']).toBeDefined();
138
+ });
139
+
140
+ test('rejects late slot when next-day spillover guests exceed capacity', () => {
141
+ const data = setupBowlingData(3);
142
+
143
+ getDailyGuestCounts.mockReturnValue({
144
+ guestCounts: makeDinnerGuestCounts(),
145
+ shiftsInfo: [],
146
+ });
147
+
148
+ // Next day has 40 guests already (full capacity)
149
+ mockGetGuestCountAtHour.mockImplementation((_data, _res, timeStr, dateStr) => {
150
+ if (dateStr === '2025-02-21') return 40;
151
+ return 0;
152
+ });
153
+
154
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, 180);
155
+
156
+ expect(result['23:00']).toBeUndefined();
157
+ });
158
+
159
+ test('rejects cross-midnight when hoursOpenedPastMidnight is 0', () => {
160
+ const data = setupBowlingData(0);
161
+
162
+ getDailyGuestCounts.mockReturnValue({
163
+ guestCounts: makeDinnerGuestCounts(),
164
+ shiftsInfo: [],
165
+ });
166
+
167
+ mockGetGuestCountAtHour.mockReturnValue(0);
168
+
169
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, 180);
170
+
171
+ // 23:00 + 180min crosses midnight, but hoursOpenedPastMidnight=0 disallows it
172
+ expect(result['23:00']).toBeUndefined();
173
+ });
174
+
175
+ test('rejects slot when spillover exceeds hoursOpenedPastMidnight limit', () => {
176
+ const data = setupBowlingData(1); // Only 1 hour past midnight
177
+
178
+ getDailyGuestCounts.mockReturnValue({
179
+ guestCounts: makeDinnerGuestCounts(),
180
+ shiftsInfo: [],
181
+ });
182
+
183
+ mockGetGuestCountAtHour.mockReturnValue(0);
184
+
185
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, 180);
186
+
187
+ // 23:00 + 180min = 02:00, which is 2h past midnight > 1h limit
188
+ expect(result['23:00']).toBeUndefined();
189
+ });
190
+
191
+ test('per-day: Friday allows cross-midnight, Monday does not', () => {
192
+ // Mon-Thu: 0, Fri-Sun: 2
193
+ const data = setupPerDayData({
194
+ Monday: 0, Tuesday: 0, Wednesday: 0, Thursday: 0,
195
+ Friday: 2, Saturday: 2, Sunday: 2,
196
+ });
197
+
198
+ getDailyGuestCounts.mockReturnValue({
199
+ guestCounts: makeDinnerGuestCounts(),
200
+ shiftsInfo: [],
201
+ });
202
+
203
+ mockGetGuestCountAtHour.mockReturnValue(0);
204
+
205
+ // 2025-02-21 is Friday
206
+ const fridayResult = timeblocksAvailable(data, '2025-02-21', [], 2, [], null, false, 180);
207
+ // 23:00 + 180min = 02:00, within 2h limit on Friday
208
+ expect(fridayResult['23:00']).toBeDefined();
209
+
210
+ // 2025-02-17 is Monday
211
+ const mondayResult = timeblocksAvailable(data, '2025-02-17', [], 2, [], null, false, 180);
212
+ // 23:00 + 180min = 02:00, but hoursOpenedPastMidnight=0 on Monday
213
+ expect(mondayResult['23:00']).toBeUndefined();
214
+ });
215
+
216
+ test('backward compat: reads from general-settings when dinner schemeSettings missing', () => {
217
+ // Old format: hoursOpenedPastMidnight in general-settings only
218
+ const data = {
219
+ 'general-settings': {
220
+ duurReservatie: '60',
221
+ intervalReservatie: '30',
222
+ hoursOpenedPastMidnight: '3',
223
+ },
224
+ };
225
+
226
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, dateStr, mealType) => {
227
+ if (mealType === 'dinner') {
228
+ return {
229
+ enabled: true,
230
+ startTime: '16:00',
231
+ endTime: '24:00',
232
+ maxCapacity: 40,
233
+ };
234
+ }
235
+ return null;
236
+ });
237
+
238
+ getMealTypesWithShifts.mockReturnValue([]);
239
+
240
+ getDailyGuestCounts.mockReturnValue({
241
+ guestCounts: makeDinnerGuestCounts(),
242
+ shiftsInfo: [],
243
+ });
244
+
245
+ mockGetGuestCountAtHour.mockReturnValue(0);
246
+
247
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, 180);
248
+
249
+ // Should still work via general-settings fallback
250
+ expect(result['23:00']).toBeDefined();
251
+ });
252
+
253
+ test('non-bowling: 22:30 is last slot, 23:00 not bookable', () => {
254
+ const data = {
255
+ 'general-settings': {
256
+ duurReservatie: '120',
257
+ intervalReservatie: '30',
258
+ },
259
+ };
260
+
261
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, dateStr, mealType) => {
262
+ if (mealType === 'dinner') {
263
+ return {
264
+ enabled: true,
265
+ startTime: '16:00',
266
+ endTime: '25:00', // 23:00 + 120min default (via addDuurReservatieToEndTime)
267
+ maxCapacity: 40,
268
+ };
269
+ }
270
+ return null;
271
+ });
272
+
273
+ getMealTypesWithShifts.mockReturnValue([]);
274
+
275
+ // Mimic original production shift boundaries (strict < for end)
276
+ getMealTypeByTime.mockImplementation((timeStr) => {
277
+ const [h, m] = timeStr.split(':').map(Number);
278
+ const time = h * 60 + m;
279
+ if (time >= 420 && time < 660) return 'breakfast';
280
+ if (time >= 660 && time < 960) return 'lunch';
281
+ if (time >= 960 && time < 1380) return 'dinner';
282
+ return null;
283
+ });
284
+
285
+ // guestCounts covers the full extended meal period
286
+ const guestCounts = {};
287
+ for (let t = 960; t <= 1500; t += 30) {
288
+ const h = Math.floor(t / 60);
289
+ const m = t % 60;
290
+ const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
291
+ guestCounts[key] = 38;
292
+ }
293
+
294
+ getDailyGuestCounts.mockReturnValue({
295
+ guestCounts,
296
+ shiftsInfo: [],
297
+ });
298
+
299
+ mockGetGuestCountAtHour.mockReturnValue(0);
300
+
301
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, null);
302
+
303
+ // Standard restaurant: 18:00 should be available
304
+ expect(result['18:00']).toBeDefined();
305
+ // 22:30 is last valid slot (getMealTypeByTime returns 'dinner' for < 23:00)
306
+ expect(result['22:30']).toBeDefined();
307
+ // 23:00: getMealTypeByTime returns null (strict < 23:00) → not bookable
308
+ expect(result['23:00']).toBeUndefined();
309
+ expect(result['23:30']).toBeUndefined();
310
+ expect(result['24:00']).toBeUndefined();
311
+ });
312
+ });
@@ -0,0 +1,271 @@
1
+ // __tests__/edgeCases.test.js
2
+ // Edge case tests for bowling vs non-bowling and per-day hoursOpenedPastMidnight
3
+
4
+ jest.mock('../restaurant_data/exceptions', () => ({
5
+ getDataByDateAndMealWithExceptions: jest.fn(),
6
+ getDataByDateAndTimeWithExceptions: jest.fn(),
7
+ }));
8
+
9
+ jest.mock('../restaurant_data/openinghours', () => ({
10
+ getMealTypeByTime: jest.fn(),
11
+ parseTime: jest.fn((timeStr) => {
12
+ const [h, m] = timeStr.split(':').map(Number);
13
+ return h * 60 + m;
14
+ }),
15
+ daysOfWeekEnglish: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
16
+ }));
17
+
18
+ jest.mock('../processing/dailyGuestCounts', () => ({
19
+ getDailyGuestCounts: jest.fn(),
20
+ }));
21
+
22
+ jest.mock('../processing/mealTypeCount', () => ({
23
+ getMealTypesWithShifts: jest.fn(),
24
+ }));
25
+
26
+ jest.mock('../reservation_data/counter', () => ({
27
+ getGuestCountAtHour: jest.fn(),
28
+ }));
29
+
30
+ const { timeblocksAvailable } = require('../processing/timeblocksAvailable');
31
+ const { getDataByDateAndMealWithExceptions } = require('../restaurant_data/exceptions');
32
+ const { getDailyGuestCounts } = require('../processing/dailyGuestCounts');
33
+ const { getMealTypesWithShifts } = require('../processing/mealTypeCount');
34
+ const { getGuestCountAtHour: mockGetGuestCountAtHour } = require('../reservation_data/counter');
35
+ const { getMealTypeByTime } = require('../restaurant_data/openinghours');
36
+
37
+ // Helper: generate guestCounts from startMin to endMin (inclusive) at given interval
38
+ function makeGuestCounts(startMin, endMin, interval, available = 38) {
39
+ const gc = {};
40
+ for (let t = startMin; t <= endMin; t += interval) {
41
+ const h = Math.floor(t / 60);
42
+ const m = t % 60;
43
+ gc[`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`] = available;
44
+ }
45
+ return gc;
46
+ }
47
+
48
+ describe('Edge cases: non-bowling restaurants', () => {
49
+ beforeEach(() => {
50
+ jest.clearAllMocks();
51
+ getMealTypesWithShifts.mockReturnValue([]);
52
+ mockGetGuestCountAtHour.mockReturnValue(0);
53
+ // Mimic original production shift boundaries (strict < for end)
54
+ getMealTypeByTime.mockImplementation((timeStr) => {
55
+ const [h, m] = timeStr.split(':').map(Number);
56
+ const time = h * 60 + m;
57
+ if (time >= 420 && time < 660) return 'breakfast';
58
+ if (time >= 660 && time < 960) return 'lunch';
59
+ if (time >= 960 && time < 1380) return 'dinner';
60
+ return null;
61
+ });
62
+ });
63
+
64
+ test('regular restaurant (no midnight setting): 22:45 is last slot, 23:00 not bookable', () => {
65
+ const data = {
66
+ 'general-settings': {
67
+ duurReservatie: '120',
68
+ intervalReservatie: '15',
69
+ },
70
+ };
71
+
72
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
73
+ if (mealType === 'dinner') {
74
+ return { enabled: true, startTime: '16:00', endTime: '25:00', maxCapacity: 40 };
75
+ }
76
+ return null;
77
+ });
78
+
79
+ // guestCounts 16:00 to 25:00 at 15min interval
80
+ getDailyGuestCounts.mockReturnValue({
81
+ guestCounts: makeGuestCounts(960, 1500, 15),
82
+ shiftsInfo: [],
83
+ });
84
+
85
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, null);
86
+
87
+ expect(result['22:00']).toBeDefined();
88
+ expect(result['22:45']).toBeDefined(); // last valid slot (getMealTypeByTime returns 'dinner' for < 23:00)
89
+ expect(result['23:00']).toBeUndefined(); // getMealTypeByTime returns null for 23:00 (strict <)
90
+ expect(result['23:15']).toBeUndefined();
91
+ expect(result['23:30']).toBeUndefined();
92
+ expect(result['24:00']).toBeUndefined();
93
+ });
94
+
95
+ test('regular restaurant with 15min interval and 60min duration: 22:45 is last slot', () => {
96
+ const data = {
97
+ 'general-settings': {
98
+ duurReservatie: '60',
99
+ intervalReservatie: '15',
100
+ },
101
+ };
102
+
103
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
104
+ if (mealType === 'dinner') {
105
+ // 23:00 + 60min extension = 24:00
106
+ return { enabled: true, startTime: '16:00', endTime: '24:00', maxCapacity: 40 };
107
+ }
108
+ return null;
109
+ });
110
+
111
+ getDailyGuestCounts.mockReturnValue({
112
+ guestCounts: makeGuestCounts(960, 1440, 15),
113
+ shiftsInfo: [],
114
+ });
115
+
116
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, null);
117
+
118
+ expect(result['22:45']).toBeDefined(); // last valid (getMealTypeByTime returns 'dinner')
119
+ expect(result['23:00']).toBeUndefined(); // getMealTypeByTime returns null for 23:00
120
+ expect(result['23:15']).toBeUndefined();
121
+ expect(result['23:30']).toBeUndefined();
122
+ expect(result['24:00']).toBeUndefined();
123
+ });
124
+
125
+ test('regular restaurant lunch: no slots after lunch closing time', () => {
126
+ const data = {
127
+ 'general-settings': {
128
+ duurReservatie: '120',
129
+ intervalReservatie: '15',
130
+ },
131
+ };
132
+
133
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
134
+ if (mealType === 'lunch') {
135
+ // lunch 11:00-14:00, extended by 120min to 16:00
136
+ return { enabled: true, startTime: '11:00', endTime: '16:00', maxCapacity: 40 };
137
+ }
138
+ return null;
139
+ });
140
+
141
+ getDailyGuestCounts.mockReturnValue({
142
+ guestCounts: makeGuestCounts(660, 960, 15),
143
+ shiftsInfo: [],
144
+ });
145
+
146
+ const result = timeblocksAvailable(data, '2025-02-20', [], 2, [], null, false, null);
147
+
148
+ expect(result['13:45']).toBeDefined();
149
+ expect(result['14:00']).toBeDefined(); // original closing
150
+ expect(result['14:15']).toBeUndefined(); // past closing
151
+ expect(result['15:00']).toBeUndefined();
152
+ });
153
+ });
154
+
155
+ describe('Edge cases: bowling per-day', () => {
156
+ beforeEach(() => {
157
+ jest.clearAllMocks();
158
+ getMealTypesWithShifts.mockReturnValue([]);
159
+ mockGetGuestCountAtHour.mockReturnValue(0);
160
+ });
161
+
162
+ test('Friday=1hr, Saturday=2hr: Saturday allows more spillover than Friday', () => {
163
+ const data = {
164
+ 'general-settings': { duurReservatie: '60', intervalReservatie: '15' },
165
+ 'openinghours-dinner': {
166
+ schemeSettings: {
167
+ Monday: { hoursOpenedPastMidnight: 0 },
168
+ Tuesday: { hoursOpenedPastMidnight: 0 },
169
+ Wednesday: { hoursOpenedPastMidnight: 0 },
170
+ Thursday: { hoursOpenedPastMidnight: 0 },
171
+ Friday: { hoursOpenedPastMidnight: 1 },
172
+ Saturday: { hoursOpenedPastMidnight: 2 },
173
+ Sunday: { hoursOpenedPastMidnight: 1 },
174
+ },
175
+ },
176
+ };
177
+
178
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
179
+ if (mealType === 'dinner') {
180
+ return { enabled: true, startTime: '16:00', endTime: '24:00', maxCapacity: 40 };
181
+ }
182
+ return null;
183
+ });
184
+
185
+ getDailyGuestCounts.mockReturnValue({
186
+ guestCounts: makeGuestCounts(960, 1440, 15),
187
+ shiftsInfo: [],
188
+ });
189
+
190
+ // 2025-02-21 is Friday
191
+ const fri = timeblocksAvailable(data, '2025-02-21', [], 2, [], null, false, 90);
192
+ // 23:30 + 90min = 01:00, within 1hr limit on Friday
193
+ expect(fri['23:30']).toBeDefined();
194
+ // 23:45 + 90min = 01:15, exceeds 1hr limit on Friday
195
+ expect(fri['23:45']).toBeUndefined();
196
+
197
+ // 2025-02-22 is Saturday
198
+ const sat = timeblocksAvailable(data, '2025-02-22', [], 2, [], null, false, 90);
199
+ // 23:45 + 90min = 01:15, within 2hr limit on Saturday
200
+ expect(sat['23:45']).toBeDefined();
201
+ });
202
+
203
+ test('bowling Monday (hoursOpenedPastMidnight=0): 23:00 with short duration OK, 23:15 not', () => {
204
+ const data = {
205
+ 'general-settings': { duurReservatie: '60', intervalReservatie: '15' },
206
+ 'openinghours-dinner': {
207
+ schemeSettings: {
208
+ Monday: { hoursOpenedPastMidnight: 0 },
209
+ Friday: { hoursOpenedPastMidnight: 1 },
210
+ },
211
+ },
212
+ };
213
+
214
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
215
+ if (mealType === 'dinner') {
216
+ return { enabled: true, startTime: '16:00', endTime: '24:00', maxCapacity: 40 };
217
+ }
218
+ return null;
219
+ });
220
+
221
+ getDailyGuestCounts.mockReturnValue({
222
+ guestCounts: makeGuestCounts(960, 1440, 15),
223
+ shiftsInfo: [],
224
+ });
225
+
226
+ // 2025-02-17 is Monday, 60min duration
227
+ const result = timeblocksAvailable(data, '2025-02-17', [], 2, [], null, false, 60);
228
+
229
+ // 23:00 + 60min = 24:00 (midnight exactly), hoursOpenedPastMidnight=0 allows up to midnight
230
+ expect(result['23:00']).toBeDefined();
231
+ // 23:15 + 60min = 24:15, past midnight, not allowed
232
+ expect(result['23:15']).toBeUndefined();
233
+ // 24:00 + 60min = 25:00, past midnight, not allowed
234
+ expect(result['24:00']).toBeUndefined();
235
+ });
236
+
237
+ test('bowling Friday (hoursOpenedPastMidnight=1): 23:30, 24:00 show', () => {
238
+ const data = {
239
+ 'general-settings': { duurReservatie: '60', intervalReservatie: '15' },
240
+ 'openinghours-dinner': {
241
+ schemeSettings: {
242
+ Monday: { hoursOpenedPastMidnight: 0 },
243
+ Friday: { hoursOpenedPastMidnight: 1 },
244
+ },
245
+ },
246
+ };
247
+
248
+ getDataByDateAndMealWithExceptions.mockImplementation((_data, _date, mealType) => {
249
+ if (mealType === 'dinner') {
250
+ return { enabled: true, startTime: '16:00', endTime: '24:00', maxCapacity: 40 };
251
+ }
252
+ return null;
253
+ });
254
+
255
+ getDailyGuestCounts.mockReturnValue({
256
+ guestCounts: makeGuestCounts(960, 1440, 15),
257
+ shiftsInfo: [],
258
+ });
259
+
260
+ // 2025-02-21 is Friday, 60min duration
261
+ const result = timeblocksAvailable(data, '2025-02-21', [], 2, [], null, false, 60);
262
+
263
+ expect(result['23:00']).toBeDefined();
264
+ expect(result['23:15']).toBeDefined();
265
+ expect(result['23:30']).toBeDefined();
266
+ expect(result['23:45']).toBeDefined();
267
+ expect(result['24:00']).toBeDefined(); // 24:00 + 60 = 25:00, within 1hr? No...
268
+
269
+ // Wait: 24:00 + 60min = 25:00 → endMinutes=1500, limit=1440+60=1500 → 1500>1500 false → OK
270
+ });
271
+ });