@happychef/algorithm 1.2.32 → 1.3.2

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 (56) hide show
  1. package/.github/workflows/ci-cd.yml +80 -80
  2. package/CHANGELOG.md +8 -8
  3. package/RESERVERINGEN_GIDS.md +986 -986
  4. package/assignTables.js +444 -444
  5. package/changes/2025/December/PR2___change.md +14 -14
  6. package/changes/2025/December/PR3_add__change.md +20 -20
  7. package/changes/2025/December/PR4___.md +15 -15
  8. package/changes/2025/December/PR5___.md +15 -15
  9. package/changes/2025/December/PR6__del_.md +17 -17
  10. package/changes/2025/December/PR7_add__change.md +21 -21
  11. package/changes/2026/February/PR15_add__change.md +21 -21
  12. package/changes/2026/February/PR16_add_getDateClosingReasons.md +31 -0
  13. package/changes/2026/January/PR10_add__change.md +21 -21
  14. package/changes/2026/January/PR11_add__change.md +19 -19
  15. package/changes/2026/January/PR12_add__.md +21 -21
  16. package/changes/2026/January/PR13_add__change.md +20 -20
  17. package/changes/2026/January/PR14_add__change.md +19 -19
  18. package/changes/2026/January/PR8_add__change.md +38 -38
  19. package/changes/2026/January/PR9_add__change.md +19 -19
  20. package/filters/maxArrivalsFilter.js +114 -114
  21. package/filters/maxGroupsFilter.js +221 -221
  22. package/filters/timeFilter.js +89 -89
  23. package/getAvailableTimeblocks.js +158 -158
  24. package/getDateClosingReasons.js +193 -0
  25. package/grouping.js +162 -162
  26. package/index.js +43 -42
  27. package/isDateAvailable.js +80 -80
  28. package/isDateAvailableWithTableCheck.js +172 -172
  29. package/isTimeAvailable.js +26 -26
  30. package/package.json +27 -27
  31. package/processing/dailyGuestCounts.js +73 -73
  32. package/processing/mealTypeCount.js +133 -133
  33. package/processing/timeblocksAvailable.js +182 -182
  34. package/reservation_data/counter.js +74 -74
  35. package/restaurant_data/exceptions.js +150 -150
  36. package/restaurant_data/openinghours.js +142 -156
  37. package/simulateTableAssignment.js +726 -726
  38. package/tableHelpers.js +209 -209
  39. package/tables/time/parseTime.js +19 -19
  40. package/tables/time/shifts.js +7 -7
  41. package/tables/utils/calculateDistance.js +13 -13
  42. package/tables/utils/isTableFreeForAllSlots.js +14 -14
  43. package/tables/utils/isTemporaryTableValid.js +39 -39
  44. package/test/test_counter.js +194 -194
  45. package/test/test_dailyCount.js +81 -81
  46. package/test/test_datesAvailable.js +106 -106
  47. package/test/test_exceptions.js +172 -172
  48. package/test/test_isDateAvailable.js +330 -330
  49. package/test/test_mealTypeCount.js +54 -54
  50. package/test/test_timesAvailable.js +88 -88
  51. package/test-meal-stop-fix.js +147 -147
  52. package/test-meal-stop-simple.js +93 -93
  53. package/test.js +336 -336
  54. package/bundle_entry.js +0 -100
  55. package/moment-timezone-shim.js +0 -179
  56. package/nul +0 -0
@@ -1,158 +1,158 @@
1
- // getAvailableTimeblocks.js
2
-
3
- const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
4
- const { parseTime, getMealTypeByTime } = require('./tableHelpers');
5
-
6
- /**
7
- * Parses a time string in "HH:MM" format into a Date object on a specific date.
8
- * @param {string} dateStr - The date string in "YYYY-MM-DD" format.
9
- * @param {string} timeStr - Time string in "HH:MM" format.
10
- * @param {string} timeZone - The IANA time zone identifier (not used, kept for compatibility).
11
- * @returns {Date} Date object representing the time on the specified date.
12
- */
13
- function parseDateTimeInTimeZone(dateStr, timeStr, timeZone) {
14
- const [year, month, day] = dateStr.split('-').map(Number);
15
- const [hours, minutes] = timeStr.split(':').map(Number);
16
-
17
- // Create a simple date object for the given date and time
18
- // This represents the local time on that date
19
- return new Date(year, month - 1, day, hours, minutes);
20
- }
21
-
22
- /**
23
- * Gets the available time blocks or shifts for a reservation, considering 'uurOpVoorhand' and 'dagenInToekomst'.
24
- * @param {Object} data - The main data object containing settings and meal information.
25
- * @param {string} dateStr - The date string in "YYYY-MM-DD" format.
26
- * @param {Array} reservations - An array of reservation objects.
27
- * @param {number} guests - The number of guests for the reservation.
28
- * @param {Array} blockedSlots - Optional array of blocked time slots to exclude.
29
- * @param {string|null} giftcard - Optional giftcard to filter times by meal.
30
- * @param {boolean} isAdmin - Optional flag to bypass time restrictions for admin users.
31
- * @returns {Object} - Returns a pruned object of available time blocks or shifts, or an empty object if out of range.
32
- */
33
- function getAvailableTimeblocks(data, dateStr, reservations, guests, blockedSlots = [], giftcard = null, isAdmin = false, duration = null) {
34
- // Get 'uurOpVoorhand' from general settings
35
- let uurOpVoorhand = 0;
36
- if (
37
- data['general-settings'] &&
38
- data['general-settings'].uurOpVoorhand &&
39
- parseInt(data['general-settings'].uurOpVoorhand, 10) >= 0
40
- ) {
41
- uurOpVoorhand = parseInt(data['general-settings'].uurOpVoorhand, 10);
42
- }
43
-
44
- // Get 'dagenInToekomst' from general settings
45
- let dagenInToekomst = 90; // Default if not defined
46
- if (
47
- data['general-settings'] &&
48
- data['general-settings'].dagenInToekomst &&
49
- parseInt(data['general-settings'].dagenInToekomst, 10) > 0
50
- ) {
51
- dagenInToekomst = parseInt(data['general-settings'].dagenInToekomst, 10);
52
- }
53
-
54
- // Time zone for CEST/CET (Europe/Amsterdam)
55
- const timeZone = 'Europe/Amsterdam';
56
-
57
- // Current date/time in local timezone (system time)
58
- // Note: We assume the server is running in the correct timezone
59
- const now = new Date();
60
- const currentTimeInTimeZone = now;
61
-
62
- // Calculate the maximum allowed date
63
- const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
64
- maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
65
- maxAllowedDate.setHours(23, 59, 59, 999);
66
-
67
- // Parse the target date (just the date part, no time)
68
- const [year, month, day] = dateStr.split('-').map(Number);
69
- const targetDateInTimeZone = new Date(year, month - 1, day);
70
-
71
- // Check if targetDateInTimeZone is within dagenInToekomst (skip for admin)
72
- if (!isAdmin && targetDateInTimeZone > maxAllowedDate) {
73
- // Out of allowed range, return empty object
74
- return {};
75
- }
76
-
77
- // Check if the target date is today in the specified time zone
78
- const isToday =
79
- currentTimeInTimeZone.toDateString() === targetDateInTimeZone.toDateString();
80
-
81
- // Get available time blocks or shifts
82
- const availableTimeblocks = timeblocksAvailable(data, dateStr, reservations, guests, blockedSlots, giftcard, isAdmin, duration);
83
-
84
- // If the date is today and uurOpVoorhand is greater than zero, prune time blocks (skip for admin)
85
- if (!isAdmin && isToday && uurOpVoorhand >= 0) {
86
- const cutoffTime = new Date(currentTimeInTimeZone.getTime());
87
- cutoffTime.setHours(cutoffTime.getHours() + uurOpVoorhand);
88
-
89
- for (const [key, value] of Object.entries(availableTimeblocks)) {
90
- let timeStr = key;
91
-
92
- const timeBlockDateTime = parseDateTimeInTimeZone(dateStr, timeStr, timeZone);
93
-
94
- if (timeBlockDateTime < cutoffTime) {
95
- delete availableTimeblocks[key];
96
- }
97
- }
98
- }
99
-
100
- // Apply last booking time filter (lunchStop, dinerStop, ontbijtStop) - skip for admin
101
- // IMPORTANT: This filter should only apply to TODAY, just like uurOpVoorhand
102
- // The filter compares CURRENT TIME in Brussels against the stop time,
103
- // not the reservation time slot against the stop time
104
- if (!isAdmin && isToday) {
105
- const settings = data?.['general-settings'] || {};
106
- const breakfastStop = settings.ontbijtStop || null;
107
- const lunchStop = settings.lunchStop || null;
108
- const dinnerStop = settings.dinerStop || null;
109
-
110
- // Only apply if at least one stop time is configured
111
- if (breakfastStop || lunchStop || dinnerStop) {
112
- // Get current time in Brussels timezone (in minutes since midnight)
113
- // Using Intl.DateTimeFormat for reliable timezone conversion
114
- const formatter = new Intl.DateTimeFormat('en-US', {
115
- timeZone: 'Europe/Brussels',
116
- hour: '2-digit',
117
- minute: '2-digit',
118
- hour12: false,
119
- });
120
- const parts = formatter.formatToParts(now);
121
- const timeParts = Object.fromEntries(parts.map(p => [p.type, p.value]));
122
- const currentTimeMinutes = parseInt(timeParts.hour, 10) * 60 + parseInt(timeParts.minute, 10);
123
-
124
- // Check if current time has passed any stop times
125
- const breakfastStopMinutes = breakfastStop ? parseTime(breakfastStop) : null;
126
- const lunchStopMinutes = lunchStop ? parseTime(lunchStop) : null;
127
- const dinnerStopMinutes = dinnerStop ? parseTime(dinnerStop) : null;
128
-
129
- const breakfastStopped = breakfastStopMinutes !== null && currentTimeMinutes >= breakfastStopMinutes;
130
- const lunchStopped = lunchStopMinutes !== null && currentTimeMinutes >= lunchStopMinutes;
131
- const dinnerStopped = dinnerStopMinutes !== null && currentTimeMinutes >= dinnerStopMinutes;
132
-
133
- // Remove ALL slots of a meal type if current time >= stop time for that meal
134
- for (const time in availableTimeblocks) {
135
- const mealType = getMealTypeByTime(time);
136
- let shouldRemove = false;
137
-
138
- if (mealType === 'breakfast' && breakfastStopped) {
139
- shouldRemove = true;
140
- } else if (mealType === 'lunch' && lunchStopped) {
141
- shouldRemove = true;
142
- } else if (mealType === 'dinner' && dinnerStopped) {
143
- shouldRemove = true;
144
- }
145
-
146
- if (shouldRemove) {
147
- delete availableTimeblocks[time];
148
- }
149
- }
150
- }
151
- }
152
-
153
- return availableTimeblocks;
154
- }
155
-
156
- module.exports = {
157
- getAvailableTimeblocks,
158
- };
1
+ // getAvailableTimeblocks.js
2
+
3
+ const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
4
+ const { parseTime, getMealTypeByTime } = require('./tableHelpers');
5
+
6
+ /**
7
+ * Parses a time string in "HH:MM" format into a Date object on a specific date.
8
+ * @param {string} dateStr - The date string in "YYYY-MM-DD" format.
9
+ * @param {string} timeStr - Time string in "HH:MM" format.
10
+ * @param {string} timeZone - The IANA time zone identifier (not used, kept for compatibility).
11
+ * @returns {Date} Date object representing the time on the specified date.
12
+ */
13
+ function parseDateTimeInTimeZone(dateStr, timeStr, timeZone) {
14
+ const [year, month, day] = dateStr.split('-').map(Number);
15
+ const [hours, minutes] = timeStr.split(':').map(Number);
16
+
17
+ // Create a simple date object for the given date and time
18
+ // This represents the local time on that date
19
+ return new Date(year, month - 1, day, hours, minutes);
20
+ }
21
+
22
+ /**
23
+ * Gets the available time blocks or shifts for a reservation, considering 'uurOpVoorhand' and 'dagenInToekomst'.
24
+ * @param {Object} data - The main data object containing settings and meal information.
25
+ * @param {string} dateStr - The date string in "YYYY-MM-DD" format.
26
+ * @param {Array} reservations - An array of reservation objects.
27
+ * @param {number} guests - The number of guests for the reservation.
28
+ * @param {Array} blockedSlots - Optional array of blocked time slots to exclude.
29
+ * @param {string|null} giftcard - Optional giftcard to filter times by meal.
30
+ * @param {boolean} isAdmin - Optional flag to bypass time restrictions for admin users.
31
+ * @returns {Object} - Returns a pruned object of available time blocks or shifts, or an empty object if out of range.
32
+ */
33
+ function getAvailableTimeblocks(data, dateStr, reservations, guests, blockedSlots = [], giftcard = null, isAdmin = false, duration = null) {
34
+ // Get 'uurOpVoorhand' from general settings
35
+ let uurOpVoorhand = 0;
36
+ if (
37
+ data['general-settings'] &&
38
+ data['general-settings'].uurOpVoorhand &&
39
+ parseInt(data['general-settings'].uurOpVoorhand, 10) >= 0
40
+ ) {
41
+ uurOpVoorhand = parseInt(data['general-settings'].uurOpVoorhand, 10);
42
+ }
43
+
44
+ // Get 'dagenInToekomst' from general settings
45
+ let dagenInToekomst = 90; // Default if not defined
46
+ if (
47
+ data['general-settings'] &&
48
+ data['general-settings'].dagenInToekomst &&
49
+ parseInt(data['general-settings'].dagenInToekomst, 10) > 0
50
+ ) {
51
+ dagenInToekomst = parseInt(data['general-settings'].dagenInToekomst, 10);
52
+ }
53
+
54
+ // Time zone for CEST/CET (Europe/Amsterdam)
55
+ const timeZone = 'Europe/Amsterdam';
56
+
57
+ // Current date/time in local timezone (system time)
58
+ // Note: We assume the server is running in the correct timezone
59
+ const now = new Date();
60
+ const currentTimeInTimeZone = now;
61
+
62
+ // Calculate the maximum allowed date
63
+ const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
64
+ maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
65
+ maxAllowedDate.setHours(23, 59, 59, 999);
66
+
67
+ // Parse the target date (just the date part, no time)
68
+ const [year, month, day] = dateStr.split('-').map(Number);
69
+ const targetDateInTimeZone = new Date(year, month - 1, day);
70
+
71
+ // Check if targetDateInTimeZone is within dagenInToekomst (skip for admin)
72
+ if (!isAdmin && targetDateInTimeZone > maxAllowedDate) {
73
+ // Out of allowed range, return empty object
74
+ return {};
75
+ }
76
+
77
+ // Check if the target date is today in the specified time zone
78
+ const isToday =
79
+ currentTimeInTimeZone.toDateString() === targetDateInTimeZone.toDateString();
80
+
81
+ // Get available time blocks or shifts
82
+ const availableTimeblocks = timeblocksAvailable(data, dateStr, reservations, guests, blockedSlots, giftcard, isAdmin, duration);
83
+
84
+ // If the date is today and uurOpVoorhand is greater than zero, prune time blocks (skip for admin)
85
+ if (!isAdmin && isToday && uurOpVoorhand >= 0) {
86
+ const cutoffTime = new Date(currentTimeInTimeZone.getTime());
87
+ cutoffTime.setHours(cutoffTime.getHours() + uurOpVoorhand);
88
+
89
+ for (const [key, value] of Object.entries(availableTimeblocks)) {
90
+ let timeStr = key;
91
+
92
+ const timeBlockDateTime = parseDateTimeInTimeZone(dateStr, timeStr, timeZone);
93
+
94
+ if (timeBlockDateTime < cutoffTime) {
95
+ delete availableTimeblocks[key];
96
+ }
97
+ }
98
+ }
99
+
100
+ // Apply last booking time filter (lunchStop, dinerStop, ontbijtStop) - skip for admin
101
+ // IMPORTANT: This filter should only apply to TODAY, just like uurOpVoorhand
102
+ // The filter compares CURRENT TIME in Brussels against the stop time,
103
+ // not the reservation time slot against the stop time
104
+ if (!isAdmin && isToday) {
105
+ const settings = data?.['general-settings'] || {};
106
+ const breakfastStop = settings.ontbijtStop || null;
107
+ const lunchStop = settings.lunchStop || null;
108
+ const dinnerStop = settings.dinerStop || null;
109
+
110
+ // Only apply if at least one stop time is configured
111
+ if (breakfastStop || lunchStop || dinnerStop) {
112
+ // Get current time in Brussels timezone (in minutes since midnight)
113
+ // Using Intl.DateTimeFormat for reliable timezone conversion
114
+ const formatter = new Intl.DateTimeFormat('en-US', {
115
+ timeZone: 'Europe/Brussels',
116
+ hour: '2-digit',
117
+ minute: '2-digit',
118
+ hour12: false,
119
+ });
120
+ const parts = formatter.formatToParts(now);
121
+ const timeParts = Object.fromEntries(parts.map(p => [p.type, p.value]));
122
+ const currentTimeMinutes = parseInt(timeParts.hour, 10) * 60 + parseInt(timeParts.minute, 10);
123
+
124
+ // Check if current time has passed any stop times
125
+ const breakfastStopMinutes = breakfastStop ? parseTime(breakfastStop) : null;
126
+ const lunchStopMinutes = lunchStop ? parseTime(lunchStop) : null;
127
+ const dinnerStopMinutes = dinnerStop ? parseTime(dinnerStop) : null;
128
+
129
+ const breakfastStopped = breakfastStopMinutes !== null && currentTimeMinutes >= breakfastStopMinutes;
130
+ const lunchStopped = lunchStopMinutes !== null && currentTimeMinutes >= lunchStopMinutes;
131
+ const dinnerStopped = dinnerStopMinutes !== null && currentTimeMinutes >= dinnerStopMinutes;
132
+
133
+ // Remove ALL slots of a meal type if current time >= stop time for that meal
134
+ for (const time in availableTimeblocks) {
135
+ const mealType = getMealTypeByTime(time);
136
+ let shouldRemove = false;
137
+
138
+ if (mealType === 'breakfast' && breakfastStopped) {
139
+ shouldRemove = true;
140
+ } else if (mealType === 'lunch' && lunchStopped) {
141
+ shouldRemove = true;
142
+ } else if (mealType === 'dinner' && dinnerStopped) {
143
+ shouldRemove = true;
144
+ }
145
+
146
+ if (shouldRemove) {
147
+ delete availableTimeblocks[time];
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return availableTimeblocks;
154
+ }
155
+
156
+ module.exports = {
157
+ getAvailableTimeblocks,
158
+ };
@@ -0,0 +1,193 @@
1
+ // getDateClosingReasons.js
2
+ //
3
+ // Diagnostic function that identifies the KEY reason a date is closed.
4
+ // Runs the same pipeline as Calendar.js's computeFinalTimeblocksCount,
5
+ // but sequentially tracks which stage eliminates all remaining slots.
6
+ //
7
+ // IMPORTANT: This pipeline must stay in sync with Calendar.js's
8
+ // computeFinalTimeblocksCount. If that pipeline changes, update this too.
9
+
10
+ const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
11
+ const { getAvailableTimeblocks } = require('./getAvailableTimeblocks');
12
+ const { getDataByDateAndMealWithExceptions } = require('./restaurant_data/exceptions');
13
+ const { getDataByDateAndMeal } = require('./restaurant_data/openinghours');
14
+ const { filterTimeblocksByMaxGroups } = require('./filters/maxGroupsFilter');
15
+ const { filterTimeblocksByMaxArrivals } = require('./filters/maxArrivalsFilter');
16
+ const { filterTimeblocksByStopTimes } = require('./filters/timeFilter');
17
+ const { getAvailableTimeblocksWithTableCheck } = require('./simulateTableAssignment');
18
+
19
+ /**
20
+ * Checks if a date is within the allowed future range defined by dagenInToekomst.
21
+ * Copied from isDateAvailable.js since it is not exported.
22
+ */
23
+ function isDateWithinAllowedRange(data, dateStr) {
24
+ let dagenInToekomst = 90;
25
+ if (
26
+ data['general-settings'] &&
27
+ data['general-settings'].dagenInToekomst &&
28
+ parseInt(data['general-settings'].dagenInToekomst, 10) > 0
29
+ ) {
30
+ dagenInToekomst = parseInt(data['general-settings'].dagenInToekomst, 10);
31
+ }
32
+
33
+ const timeZone = 'Europe/Amsterdam';
34
+
35
+ const now = new Date();
36
+ const currentTimeInTimeZone = new Date(
37
+ now.toLocaleString('en-US', { timeZone: timeZone })
38
+ );
39
+
40
+ const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
41
+ maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
42
+ maxAllowedDate.setHours(23, 59, 59, 999);
43
+
44
+ const [year, month, day] = dateStr.split('-').map(Number);
45
+ const targetDate = new Date(Date.UTC(year, month - 1, day));
46
+ const targetDateInTimeZone = new Date(
47
+ targetDate.toLocaleString('en-US', { timeZone: timeZone })
48
+ );
49
+
50
+ return targetDateInTimeZone <= maxAllowedDate;
51
+ }
52
+
53
+ /**
54
+ * Diagnoses why a date has no available timeblocks.
55
+ * Runs the same pipeline as Calendar.js's computeFinalTimeblocksCount,
56
+ * but tracks at which stage slots go to zero to identify the key closing reason.
57
+ *
58
+ * @param {Object} data - Restaurant data object
59
+ * @param {string} dateStr - Date in "YYYY-MM-DD" format
60
+ * @param {Array} reservations - Existing reservations
61
+ * @param {number} guests - Number of guests for the reservation
62
+ * @param {Array} blockedSlots - Blocked time slots
63
+ * @param {string|null} giftcard - Selected giftcard or null
64
+ * @param {number|null} duration - Custom duration or null
65
+ * @param {string|null} zitplaats - Selected seating area or null
66
+ * @returns {Array<{type: string}>} Array of reason objects (typically single-element)
67
+ */
68
+ function getDateClosingReasons(data, dateStr, reservations, guests, blockedSlots = [], giftcard = null, duration = null, zitplaats = null) {
69
+ const reasons = [];
70
+
71
+ // --- Stage 0: Date out of range ---
72
+ if (!isDateWithinAllowedRange(data, dateStr)) {
73
+ reasons.push({ type: 'DATE_OUT_OF_RANGE' });
74
+ return reasons;
75
+ }
76
+
77
+ // --- Stage 1: Opening hours & exceptions ---
78
+ // Check if any meal is open for this day (with exceptions applied)
79
+ const mealTypes = ['breakfast', 'lunch', 'dinner'];
80
+ let anyMealOpenWithExceptions = false;
81
+ let anyMealOpenWithoutExceptions = false;
82
+
83
+ for (const mealType of mealTypes) {
84
+ const withExceptions = getDataByDateAndMealWithExceptions(data, dateStr, mealType);
85
+ if (withExceptions) {
86
+ anyMealOpenWithExceptions = true;
87
+ }
88
+
89
+ const withoutExceptions = getDataByDateAndMeal(data, dateStr, mealType);
90
+ if (withoutExceptions) {
91
+ anyMealOpenWithoutExceptions = true;
92
+ }
93
+ }
94
+
95
+ if (!anyMealOpenWithExceptions) {
96
+ if (anyMealOpenWithoutExceptions) {
97
+ // Meals are enabled in base opening hours but closed by exception rule
98
+ reasons.push({ type: 'EXCEPTION_CLOSURE' });
99
+ } else {
100
+ // No meals enabled at all for this day of the week
101
+ reasons.push({ type: 'OPENING_HOURS_DISABLED' });
102
+ }
103
+ return reasons;
104
+ }
105
+
106
+ // --- Stage 2: Base capacity (0 reservations, no blocked slots, no giftcard) ---
107
+ // Tests if the restaurant has enough base capacity for the group size
108
+ const baseTbs = timeblocksAvailable(data, dateStr, [], guests, [], null, false, duration);
109
+ if (Object.keys(baseTbs).length === 0) {
110
+ reasons.push({ type: 'CAPACITY_INSUFFICIENT' });
111
+ return reasons;
112
+ }
113
+
114
+ // --- Stage 3: With real reservations (no blocked slots, no giftcard) ---
115
+ // Tests if existing bookings have filled all capacity
116
+ const withResTbs = timeblocksAvailable(data, dateStr, reservations, guests, [], null, false, duration);
117
+ if (Object.keys(withResTbs).length === 0) {
118
+ reasons.push({ type: 'CAPACITY_REACHED' });
119
+ return reasons;
120
+ }
121
+
122
+ // --- Stage 4: With giftcard filter (if applicable) ---
123
+ if (giftcard) {
124
+ const withGcTbs = timeblocksAvailable(data, dateStr, reservations, guests, [], giftcard, false, duration);
125
+ if (Object.keys(withGcTbs).length === 0) {
126
+ reasons.push({ type: 'GIFTCARD_MISMATCH' });
127
+ return reasons;
128
+ }
129
+ }
130
+
131
+ // --- Stage 5: With blocked slots ---
132
+ // Full timeblocksAvailable call (capacity + giftcard + blocked slots)
133
+ const fullTbs = timeblocksAvailable(data, dateStr, reservations, guests, blockedSlots, giftcard, false, duration);
134
+ if (Object.keys(fullTbs).length === 0) {
135
+ reasons.push({ type: 'BLOCKED_SLOTS' });
136
+ return reasons;
137
+ }
138
+
139
+ // --- Stage 6: getAvailableTimeblocks (adds today-only filters: uurOpVoorhand + stop times) ---
140
+ let tbs = getAvailableTimeblocks(data, dateStr, reservations, guests, blockedSlots, giftcard, false, duration);
141
+ if (Object.keys(tbs).length === 0) {
142
+ reasons.push({ type: 'TODAY_TIME_RESTRICTION' });
143
+ return reasons;
144
+ }
145
+
146
+ // --- Stage 7: Max groups filter ---
147
+ tbs = filterTimeblocksByMaxGroups(data, dateStr, tbs, reservations, guests);
148
+ if (Object.keys(tbs).length === 0) {
149
+ reasons.push({ type: 'MAX_GROUPS' });
150
+ return reasons;
151
+ }
152
+
153
+ // --- Stage 8: Max arrivals filter ---
154
+ tbs = filterTimeblocksByMaxArrivals(data, dateStr, tbs, reservations, guests);
155
+ if (Object.keys(tbs).length === 0) {
156
+ reasons.push({ type: 'MAX_ARRIVALS' });
157
+ return reasons;
158
+ }
159
+
160
+ // --- Stage 9: Table assignment (if enabled) ---
161
+ const tableSettings = data?.['table-settings'] || {};
162
+ const isTableAssignmentEnabled =
163
+ tableSettings.isInstalled === true && tableSettings.assignmentMode === 'automatic';
164
+
165
+ if (isTableAssignmentEnabled) {
166
+ const tableTbs = getAvailableTimeblocksWithTableCheck(data, dateStr, tbs, guests, reservations, zitplaats, duration);
167
+ if (Object.keys(tableTbs).length === 0) {
168
+ reasons.push({ type: 'TABLE_PLAN' });
169
+ return reasons;
170
+ }
171
+
172
+ // Re-apply max arrivals after table check (matching Calendar pipeline)
173
+ tbs = filterTimeblocksByMaxArrivals(data, dateStr, tableTbs, reservations, guests);
174
+ if (Object.keys(tbs).length === 0) {
175
+ reasons.push({ type: 'MAX_ARRIVALS' });
176
+ return reasons;
177
+ }
178
+ }
179
+
180
+ // --- Stage 10: Stop times filter ---
181
+ tbs = filterTimeblocksByStopTimes(tbs, data, dateStr);
182
+ if (Object.keys(tbs).length === 0) {
183
+ reasons.push({ type: 'TODAY_TIME_RESTRICTION' });
184
+ return reasons;
185
+ }
186
+
187
+ // If we get here, the date is actually available (reasons array is empty)
188
+ return reasons;
189
+ }
190
+
191
+ module.exports = {
192
+ getDateClosingReasons,
193
+ };