dabke 0.78.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 (194) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +187 -0
  4. package/dist/client.d.ts +14 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +42 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/client.schemas.d.ts +250 -0
  9. package/dist/client.schemas.d.ts.map +1 -0
  10. package/dist/client.schemas.js +137 -0
  11. package/dist/client.schemas.js.map +1 -0
  12. package/dist/client.types.d.ts +34 -0
  13. package/dist/client.types.d.ts.map +1 -0
  14. package/dist/client.types.js +18 -0
  15. package/dist/client.types.js.map +1 -0
  16. package/dist/cpsat/model-builder.d.ts +128 -0
  17. package/dist/cpsat/model-builder.d.ts.map +1 -0
  18. package/dist/cpsat/model-builder.js +640 -0
  19. package/dist/cpsat/model-builder.js.map +1 -0
  20. package/dist/cpsat/response.d.ts +74 -0
  21. package/dist/cpsat/response.d.ts.map +1 -0
  22. package/dist/cpsat/response.js +92 -0
  23. package/dist/cpsat/response.js.map +1 -0
  24. package/dist/cpsat/rules/assign-together.d.ts +23 -0
  25. package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
  26. package/dist/cpsat/rules/assign-together.js +78 -0
  27. package/dist/cpsat/rules/assign-together.js.map +1 -0
  28. package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
  29. package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
  30. package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
  31. package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
  32. package/dist/cpsat/rules/index.d.ts +13 -0
  33. package/dist/cpsat/rules/index.d.ts.map +1 -0
  34. package/dist/cpsat/rules/index.js +13 -0
  35. package/dist/cpsat/rules/index.js.map +1 -0
  36. package/dist/cpsat/rules/location-preference.d.ts +29 -0
  37. package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
  38. package/dist/cpsat/rules/location-preference.js +59 -0
  39. package/dist/cpsat/rules/location-preference.js.map +1 -0
  40. package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
  41. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
  42. package/dist/cpsat/rules/max-consecutive-days.js +70 -0
  43. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
  44. package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
  45. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
  46. package/dist/cpsat/rules/max-hours-day.js +159 -0
  47. package/dist/cpsat/rules/max-hours-day.js.map +1 -0
  48. package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
  49. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
  50. package/dist/cpsat/rules/max-hours-week.js +169 -0
  51. package/dist/cpsat/rules/max-hours-week.js.map +1 -0
  52. package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
  53. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
  54. package/dist/cpsat/rules/max-shifts-day.js +170 -0
  55. package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
  56. package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
  57. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
  58. package/dist/cpsat/rules/min-consecutive-days.js +104 -0
  59. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
  60. package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
  61. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
  62. package/dist/cpsat/rules/min-hours-day.js +61 -0
  63. package/dist/cpsat/rules/min-hours-day.js.map +1 -0
  64. package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
  65. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
  66. package/dist/cpsat/rules/min-hours-week.js +68 -0
  67. package/dist/cpsat/rules/min-hours-week.js.map +1 -0
  68. package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
  69. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
  70. package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
  71. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
  72. package/dist/cpsat/rules/registry.d.ts +7 -0
  73. package/dist/cpsat/rules/registry.d.ts.map +1 -0
  74. package/dist/cpsat/rules/registry.js +28 -0
  75. package/dist/cpsat/rules/registry.js.map +1 -0
  76. package/dist/cpsat/rules/resolver.d.ts +31 -0
  77. package/dist/cpsat/rules/resolver.d.ts.map +1 -0
  78. package/dist/cpsat/rules/resolver.js +124 -0
  79. package/dist/cpsat/rules/resolver.js.map +1 -0
  80. package/dist/cpsat/rules/rules.types.d.ts +32 -0
  81. package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
  82. package/dist/cpsat/rules/rules.types.js +2 -0
  83. package/dist/cpsat/rules/rules.types.js.map +1 -0
  84. package/dist/cpsat/rules/scoping.d.ts +129 -0
  85. package/dist/cpsat/rules/scoping.d.ts.map +1 -0
  86. package/dist/cpsat/rules/scoping.js +190 -0
  87. package/dist/cpsat/rules/scoping.js.map +1 -0
  88. package/dist/cpsat/rules/time-off.d.ts +78 -0
  89. package/dist/cpsat/rules/time-off.d.ts.map +1 -0
  90. package/dist/cpsat/rules/time-off.js +261 -0
  91. package/dist/cpsat/rules/time-off.js.map +1 -0
  92. package/dist/cpsat/rules.d.ts +5 -0
  93. package/dist/cpsat/rules.d.ts.map +1 -0
  94. package/dist/cpsat/rules.js +4 -0
  95. package/dist/cpsat/rules.js.map +1 -0
  96. package/dist/cpsat/semantic-time.d.ts +198 -0
  97. package/dist/cpsat/semantic-time.d.ts.map +1 -0
  98. package/dist/cpsat/semantic-time.js +222 -0
  99. package/dist/cpsat/semantic-time.js.map +1 -0
  100. package/dist/cpsat/types.d.ts +180 -0
  101. package/dist/cpsat/types.d.ts.map +1 -0
  102. package/dist/cpsat/types.js +2 -0
  103. package/dist/cpsat/types.js.map +1 -0
  104. package/dist/cpsat/utils.d.ts +47 -0
  105. package/dist/cpsat/utils.d.ts.map +1 -0
  106. package/dist/cpsat/utils.js +92 -0
  107. package/dist/cpsat/utils.js.map +1 -0
  108. package/dist/cpsat/validation-reporter.d.ts +54 -0
  109. package/dist/cpsat/validation-reporter.d.ts.map +1 -0
  110. package/dist/cpsat/validation-reporter.js +261 -0
  111. package/dist/cpsat/validation-reporter.js.map +1 -0
  112. package/dist/cpsat/validation.types.d.ts +141 -0
  113. package/dist/cpsat/validation.types.d.ts.map +1 -0
  114. package/dist/cpsat/validation.types.js +14 -0
  115. package/dist/cpsat/validation.types.js.map +1 -0
  116. package/dist/datetime.utils.d.ts +245 -0
  117. package/dist/datetime.utils.d.ts.map +1 -0
  118. package/dist/datetime.utils.js +372 -0
  119. package/dist/datetime.utils.js.map +1 -0
  120. package/dist/errors.d.ts +12 -0
  121. package/dist/errors.d.ts.map +1 -0
  122. package/dist/errors.js +17 -0
  123. package/dist/errors.js.map +1 -0
  124. package/dist/index.d.ts +112 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +116 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/llms.d.ts +5 -0
  129. package/dist/llms.d.ts.map +1 -0
  130. package/dist/llms.js +8 -0
  131. package/dist/llms.js.map +1 -0
  132. package/dist/testing/index.d.ts +12 -0
  133. package/dist/testing/index.d.ts.map +1 -0
  134. package/dist/testing/index.js +11 -0
  135. package/dist/testing/index.js.map +1 -0
  136. package/dist/testing/solver-container.d.ts +49 -0
  137. package/dist/testing/solver-container.d.ts.map +1 -0
  138. package/dist/testing/solver-container.js +127 -0
  139. package/dist/testing/solver-container.js.map +1 -0
  140. package/dist/types.d.ts +155 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +20 -0
  143. package/dist/types.js.map +1 -0
  144. package/dist/validation.d.ts +105 -0
  145. package/dist/validation.d.ts.map +1 -0
  146. package/dist/validation.js +130 -0
  147. package/dist/validation.js.map +1 -0
  148. package/llms.txt +2188 -0
  149. package/package.json +76 -0
  150. package/solver/Dockerfile +31 -0
  151. package/solver/README.md +23 -0
  152. package/solver/pyproject.toml +28 -0
  153. package/solver/src/solver/__init__.py +1 -0
  154. package/solver/src/solver/app.py +24 -0
  155. package/solver/src/solver/models.py +120 -0
  156. package/solver/src/solver/solver.py +359 -0
  157. package/solver/tests/test_solver.py +156 -0
  158. package/solver/uv.lock +661 -0
  159. package/src/client.schemas.ts +163 -0
  160. package/src/client.ts +67 -0
  161. package/src/client.types.ts +66 -0
  162. package/src/cpsat/model-builder.ts +858 -0
  163. package/src/cpsat/response.ts +130 -0
  164. package/src/cpsat/rules/assign-together.ts +96 -0
  165. package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
  166. package/src/cpsat/rules/index.ts +12 -0
  167. package/src/cpsat/rules/location-preference.ts +68 -0
  168. package/src/cpsat/rules/max-consecutive-days.ts +98 -0
  169. package/src/cpsat/rules/max-hours-day.ts +187 -0
  170. package/src/cpsat/rules/max-hours-week.ts +197 -0
  171. package/src/cpsat/rules/max-shifts-day.ts +198 -0
  172. package/src/cpsat/rules/min-consecutive-days.ts +140 -0
  173. package/src/cpsat/rules/min-hours-day.ts +69 -0
  174. package/src/cpsat/rules/min-hours-week.ts +77 -0
  175. package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
  176. package/src/cpsat/rules/registry.ts +49 -0
  177. package/src/cpsat/rules/resolver.ts +181 -0
  178. package/src/cpsat/rules/rules.types.ts +41 -0
  179. package/src/cpsat/rules/scoping.ts +340 -0
  180. package/src/cpsat/rules/time-off.ts +336 -0
  181. package/src/cpsat/rules.ts +27 -0
  182. package/src/cpsat/semantic-time.ts +463 -0
  183. package/src/cpsat/types.ts +194 -0
  184. package/src/cpsat/utils.ts +105 -0
  185. package/src/cpsat/validation-reporter.ts +366 -0
  186. package/src/cpsat/validation.types.ts +185 -0
  187. package/src/datetime.utils.ts +426 -0
  188. package/src/errors.ts +17 -0
  189. package/src/index.ts +289 -0
  190. package/src/llms.ts +9 -0
  191. package/src/testing/index.ts +12 -0
  192. package/src/testing/solver-container.ts +172 -0
  193. package/src/types.ts +191 -0
  194. package/src/validation.ts +188 -0
@@ -0,0 +1,187 @@
1
+ import * as z from "zod";
2
+ import type { CompilationRule } from "../model-builder.js";
3
+ import type { SchedulingEmployee } from "../types.js";
4
+ import type { Term } from "../types.js";
5
+ import { parseDayString, priorityToPenalty } from "../utils.js";
6
+ import { normalizeScope, withScopes, type RuleScope } from "./scoping.js";
7
+
8
+ const MaxHoursDaySchema = withScopes(
9
+ z.object({
10
+ hours: z.number().min(0),
11
+ priority: z.union([
12
+ z.literal("LOW"),
13
+ z.literal("MEDIUM"),
14
+ z.literal("HIGH"),
15
+ z.literal("MANDATORY"),
16
+ ]),
17
+ }),
18
+ {
19
+ entities: ["employees", "roles", "skills"],
20
+ times: ["dateRange", "specificDates", "dayOfWeek", "recurring"],
21
+ },
22
+ );
23
+
24
+ export type MaxHoursDayConfig = z.infer<typeof MaxHoursDaySchema>;
25
+
26
+ /**
27
+ * Limits how many hours a person can work in a single day.
28
+ *
29
+ * Supports entity scoping (people, roles, skills) and time scoping
30
+ * (date ranges, specific dates, days of week, recurring periods).
31
+ *
32
+ * @example Limit everyone to 8 hours per day
33
+ * ```ts
34
+ * createMaxHoursDayRule({
35
+ * hours: 8,
36
+ * priority: "MANDATORY",
37
+ * });
38
+ * ```
39
+ *
40
+ * @example Students limited to 4 hours on weekdays during term
41
+ * ```ts
42
+ * createMaxHoursDayRule({
43
+ * roleIds: ["student"],
44
+ * hours: 4,
45
+ * dayOfWeek: ["monday", "tuesday", "wednesday", "thursday", "friday"],
46
+ * priority: "MANDATORY",
47
+ * });
48
+ * ```
49
+ */
50
+ export function createMaxHoursDayRule(config: MaxHoursDayConfig): CompilationRule {
51
+ const parsed = MaxHoursDaySchema.parse(config);
52
+ const { hours, priority } = parsed;
53
+ const maxMinutes = hours * 60;
54
+
55
+ return {
56
+ compile(b) {
57
+ const scope = normalizeScope(parsed, b.employees);
58
+ const targetEmployees = resolveEmployees(scope, b.employees);
59
+ const activeDays = resolveActiveDays(scope, b.days);
60
+
61
+ if (targetEmployees.length === 0 || activeDays.length === 0) return;
62
+
63
+ for (const emp of targetEmployees) {
64
+ for (const day of activeDays) {
65
+ const terms: Term[] = [];
66
+ for (const pattern of b.shiftPatterns) {
67
+ if (!b.canAssign(emp, pattern)) continue;
68
+ if (!b.patternAvailableOnDay(pattern, day)) continue;
69
+ terms.push({
70
+ var: b.assignment(emp.id, pattern.id, day),
71
+ coeff: b.patternDuration(pattern.id),
72
+ });
73
+ }
74
+
75
+ if (terms.length === 0) continue;
76
+
77
+ if (priority === "MANDATORY") {
78
+ b.addLinear(terms, "<=", maxMinutes);
79
+ } else {
80
+ b.addSoftLinear(terms, "<=", maxMinutes, priorityToPenalty(priority));
81
+ }
82
+ }
83
+ }
84
+ },
85
+ };
86
+ }
87
+
88
+ function resolveEmployees(scope: RuleScope, employees: SchedulingEmployee[]): SchedulingEmployee[] {
89
+ const entity = scope.entity;
90
+ switch (entity.type) {
91
+ case "employees":
92
+ return employees.filter((e) => entity.employeeIds.includes(e.id));
93
+ case "roles":
94
+ return employees.filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)));
95
+ case "skills":
96
+ return employees.filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)));
97
+ case "global":
98
+ default:
99
+ return employees;
100
+ }
101
+ }
102
+
103
+ type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
104
+
105
+ function getDayOfWeekName(dayIndex: number): DayName {
106
+ const names: Record<number, DayName> = {
107
+ 0: "sunday",
108
+ 1: "monday",
109
+ 2: "tuesday",
110
+ 3: "wednesday",
111
+ 4: "thursday",
112
+ 5: "friday",
113
+ 6: "saturday",
114
+ };
115
+ return names[dayIndex % 7] ?? "sunday";
116
+ }
117
+
118
+ function resolveActiveDays(scope: RuleScope, allDays: string[]): string[] {
119
+ const timeScope = scope.time;
120
+
121
+ if (!timeScope) {
122
+ return allDays;
123
+ }
124
+
125
+ switch (timeScope.type) {
126
+ case "always":
127
+ return allDays;
128
+
129
+ case "dateRange": {
130
+ const start = timeScope.start;
131
+ const end = timeScope.end;
132
+ return allDays.filter((day) => day >= start && day <= end);
133
+ }
134
+
135
+ case "specificDates":
136
+ return allDays.filter((day) => timeScope.dates.includes(day));
137
+
138
+ case "dayOfWeek": {
139
+ const targetDays = new Set(timeScope.days);
140
+ return allDays.filter((day) => {
141
+ const date = parseDayString(day);
142
+ const dayName = getDayOfWeekName(date.getUTCDay());
143
+ return targetDays.has(dayName);
144
+ });
145
+ }
146
+
147
+ case "recurring": {
148
+ return allDays.filter((day) => {
149
+ const date = parseDayString(day);
150
+ const month = date.getUTCMonth() + 1;
151
+ const dayOfMonth = date.getUTCDate();
152
+
153
+ return timeScope.periods.some((period) =>
154
+ isDateInRecurringPeriod(month, dayOfMonth, period),
155
+ );
156
+ });
157
+ }
158
+
159
+ default:
160
+ return allDays;
161
+ }
162
+ }
163
+
164
+ function isDateInRecurringPeriod(
165
+ month: number,
166
+ dayOfMonth: number,
167
+ period: {
168
+ startMonth: number;
169
+ startDay: number;
170
+ endMonth: number;
171
+ endDay: number;
172
+ },
173
+ ): boolean {
174
+ const { startMonth, startDay, endMonth, endDay } = period;
175
+
176
+ if (startMonth <= endMonth) {
177
+ if (month < startMonth || month > endMonth) return false;
178
+ if (month === startMonth && dayOfMonth < startDay) return false;
179
+ if (month === endMonth && dayOfMonth > endDay) return false;
180
+ return true;
181
+ } else {
182
+ if (month > endMonth && month < startMonth) return false;
183
+ if (month === startMonth && dayOfMonth < startDay) return false;
184
+ if (month === endMonth && dayOfMonth > endDay) return false;
185
+ return true;
186
+ }
187
+ }
@@ -0,0 +1,197 @@
1
+ import * as z from "zod";
2
+ import { DayOfWeekSchema } from "../../types.js";
3
+ import type { CompilationRule } from "../model-builder.js";
4
+ import type { SchedulingEmployee, Term } from "../types.js";
5
+ import { parseDayString, priorityToPenalty, splitIntoWeeks } from "../utils.js";
6
+ import { normalizeScope, withScopes, type RuleScope } from "./scoping.js";
7
+
8
+ const MaxHoursWeekSchema = withScopes(
9
+ z.object({
10
+ hours: z.number().min(0),
11
+ priority: z.union([
12
+ z.literal("LOW"),
13
+ z.literal("MEDIUM"),
14
+ z.literal("HIGH"),
15
+ z.literal("MANDATORY"),
16
+ ]),
17
+ // Optional override; defaults to ModelBuilder.weekStartsOn
18
+ weekStartsOn: DayOfWeekSchema.optional(),
19
+ }),
20
+ {
21
+ entities: ["employees", "roles", "skills"],
22
+ times: ["dateRange", "specificDates", "dayOfWeek", "recurring"],
23
+ },
24
+ );
25
+
26
+ export type MaxHoursWeekConfig = z.infer<typeof MaxHoursWeekSchema>;
27
+
28
+ /**
29
+ * Caps total hours a person can work within each scheduling week.
30
+ *
31
+ * Supports entity scoping (people, roles, skills) and time scoping
32
+ * (date ranges, specific dates, days of week, recurring periods).
33
+ * Time scoping filters which days within each week count toward the limit.
34
+ *
35
+ * @example Limit everyone to 40 hours per week
36
+ * ```ts
37
+ * createMaxHoursWeekRule({
38
+ * hours: 40,
39
+ * priority: "HIGH",
40
+ * });
41
+ * ```
42
+ *
43
+ * @example Students limited to 20 hours during term time
44
+ * ```ts
45
+ * createMaxHoursWeekRule({
46
+ * roleIds: ["student"],
47
+ * hours: 20,
48
+ * recurringPeriods: [
49
+ * { name: "fall-term", startMonth: 9, startDay: 1, endMonth: 12, endDay: 15 },
50
+ * { name: "spring-term", startMonth: 1, startDay: 15, endMonth: 5, endDay: 31 },
51
+ * ],
52
+ * priority: "MANDATORY",
53
+ * });
54
+ * ```
55
+ */
56
+ export function createMaxHoursWeekRule(config: MaxHoursWeekConfig): CompilationRule {
57
+ const parsed = MaxHoursWeekSchema.parse(config);
58
+ const { hours, priority } = parsed;
59
+ const maxMinutes = hours * 60;
60
+
61
+ return {
62
+ compile(b) {
63
+ const scope = normalizeScope(parsed, b.employees);
64
+ const targetEmployees = resolveEmployees(scope, b.employees);
65
+ const activeDays = resolveActiveDays(scope, b.days);
66
+
67
+ if (targetEmployees.length === 0 || activeDays.length === 0) return;
68
+
69
+ const weeks = splitIntoWeeks(activeDays, parsed.weekStartsOn ?? b.weekStartsOn);
70
+
71
+ for (const emp of targetEmployees) {
72
+ for (const weekDays of weeks) {
73
+ const terms: Term[] = [];
74
+ for (const day of weekDays) {
75
+ for (const pattern of b.shiftPatterns) {
76
+ if (!b.canAssign(emp, pattern)) continue;
77
+ if (!b.patternAvailableOnDay(pattern, day)) continue;
78
+ terms.push({
79
+ var: b.assignment(emp.id, pattern.id, day),
80
+ coeff: b.patternDuration(pattern.id),
81
+ });
82
+ }
83
+ }
84
+
85
+ if (terms.length === 0) continue;
86
+
87
+ if (priority === "MANDATORY") {
88
+ b.addLinear(terms, "<=", maxMinutes);
89
+ } else {
90
+ b.addSoftLinear(terms, "<=", maxMinutes, priorityToPenalty(priority));
91
+ }
92
+ }
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ function resolveEmployees(scope: RuleScope, employees: SchedulingEmployee[]): SchedulingEmployee[] {
99
+ const entity = scope.entity;
100
+ switch (entity.type) {
101
+ case "employees":
102
+ return employees.filter((e) => entity.employeeIds.includes(e.id));
103
+ case "roles":
104
+ return employees.filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)));
105
+ case "skills":
106
+ return employees.filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)));
107
+ case "global":
108
+ default:
109
+ return employees;
110
+ }
111
+ }
112
+
113
+ type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
114
+
115
+ function getDayOfWeekName(dayIndex: number): DayName {
116
+ const names: Record<number, DayName> = {
117
+ 0: "sunday",
118
+ 1: "monday",
119
+ 2: "tuesday",
120
+ 3: "wednesday",
121
+ 4: "thursday",
122
+ 5: "friday",
123
+ 6: "saturday",
124
+ };
125
+ return names[dayIndex % 7] ?? "sunday";
126
+ }
127
+
128
+ function resolveActiveDays(scope: RuleScope, allDays: string[]): string[] {
129
+ const timeScope = scope.time;
130
+
131
+ if (!timeScope) {
132
+ return allDays;
133
+ }
134
+
135
+ switch (timeScope.type) {
136
+ case "always":
137
+ return allDays;
138
+
139
+ case "dateRange": {
140
+ const start = timeScope.start;
141
+ const end = timeScope.end;
142
+ return allDays.filter((day) => day >= start && day <= end);
143
+ }
144
+
145
+ case "specificDates":
146
+ return allDays.filter((day) => timeScope.dates.includes(day));
147
+
148
+ case "dayOfWeek": {
149
+ const targetDays = new Set(timeScope.days);
150
+ return allDays.filter((day) => {
151
+ const date = parseDayString(day);
152
+ const dayName = getDayOfWeekName(date.getUTCDay());
153
+ return targetDays.has(dayName);
154
+ });
155
+ }
156
+
157
+ case "recurring": {
158
+ return allDays.filter((day) => {
159
+ const date = parseDayString(day);
160
+ const month = date.getUTCMonth() + 1;
161
+ const dayOfMonth = date.getUTCDate();
162
+
163
+ return timeScope.periods.some((period) =>
164
+ isDateInRecurringPeriod(month, dayOfMonth, period),
165
+ );
166
+ });
167
+ }
168
+
169
+ default:
170
+ return allDays;
171
+ }
172
+ }
173
+
174
+ function isDateInRecurringPeriod(
175
+ month: number,
176
+ dayOfMonth: number,
177
+ period: {
178
+ startMonth: number;
179
+ startDay: number;
180
+ endMonth: number;
181
+ endDay: number;
182
+ },
183
+ ): boolean {
184
+ const { startMonth, startDay, endMonth, endDay } = period;
185
+
186
+ if (startMonth <= endMonth) {
187
+ if (month < startMonth || month > endMonth) return false;
188
+ if (month === startMonth && dayOfMonth < startDay) return false;
189
+ if (month === endMonth && dayOfMonth > endDay) return false;
190
+ return true;
191
+ } else {
192
+ if (month > endMonth && month < startMonth) return false;
193
+ if (month === startMonth && dayOfMonth < startDay) return false;
194
+ if (month === endMonth && dayOfMonth > endDay) return false;
195
+ return true;
196
+ }
197
+ }
@@ -0,0 +1,198 @@
1
+ import * as z from "zod";
2
+ import type { CompilationRule } from "../model-builder.js";
3
+ import type { SchedulingEmployee } from "../types.js";
4
+ import type { Term } from "../types.js";
5
+ import { parseDayString, priorityToPenalty } from "../utils.js";
6
+ import { normalizeScope, withScopes, type RuleScope } from "./scoping.js";
7
+
8
+ const MaxShiftsDaySchema = withScopes(
9
+ z.object({
10
+ shifts: z.number().int().min(1),
11
+ priority: z.union([
12
+ z.literal("LOW"),
13
+ z.literal("MEDIUM"),
14
+ z.literal("HIGH"),
15
+ z.literal("MANDATORY"),
16
+ ]),
17
+ }),
18
+ {
19
+ entities: ["employees", "roles", "skills"],
20
+ times: ["dateRange", "specificDates", "dayOfWeek", "recurring"],
21
+ },
22
+ );
23
+
24
+ export type MaxShiftsDayConfig = z.infer<typeof MaxShiftsDaySchema>;
25
+
26
+ /**
27
+ * Limits how many shifts a person can work in a single day.
28
+ *
29
+ * This rule controls the maximum number of distinct shift assignments per day,
30
+ * regardless of shift duration. For limiting total hours worked, use `max-hours-day`.
31
+ *
32
+ * Supports entity scoping (people, roles, skills) and time scoping
33
+ * (date ranges, specific dates, days of week, recurring periods).
34
+ *
35
+ * @example Limit to one shift per day (common for most schedules)
36
+ * ```ts
37
+ * createMaxShiftsDayRule({
38
+ * shifts: 1,
39
+ * priority: "MANDATORY",
40
+ * });
41
+ * ```
42
+ *
43
+ * @example Allow up to two shifts per day for part-time workers
44
+ * ```ts
45
+ * createMaxShiftsDayRule({
46
+ * roleIds: ["part-time"],
47
+ * shifts: 2,
48
+ * priority: "HIGH",
49
+ * });
50
+ * ```
51
+ *
52
+ * @example Students can work 2 shifts on weekends only
53
+ * ```ts
54
+ * createMaxShiftsDayRule({
55
+ * roleIds: ["student"],
56
+ * shifts: 2,
57
+ * dayOfWeek: ["saturday", "sunday"],
58
+ * priority: "MANDATORY",
59
+ * });
60
+ * ```
61
+ */
62
+ export function createMaxShiftsDayRule(config: MaxShiftsDayConfig): CompilationRule {
63
+ const parsed = MaxShiftsDaySchema.parse(config);
64
+ const { shifts, priority } = parsed;
65
+
66
+ return {
67
+ compile(b) {
68
+ const scope = normalizeScope(parsed, b.employees);
69
+ const targetEmployees = resolveEmployees(scope, b.employees);
70
+ const activeDays = resolveActiveDays(scope, b.days);
71
+
72
+ if (targetEmployees.length === 0 || activeDays.length === 0) return;
73
+
74
+ for (const emp of targetEmployees) {
75
+ for (const day of activeDays) {
76
+ const terms: Term[] = [];
77
+ for (const pattern of b.shiftPatterns) {
78
+ if (!b.canAssign(emp, pattern)) continue;
79
+ if (!b.patternAvailableOnDay(pattern, day)) continue;
80
+ terms.push({
81
+ var: b.assignment(emp.id, pattern.id, day),
82
+ coeff: 1,
83
+ });
84
+ }
85
+
86
+ if (terms.length === 0) continue;
87
+
88
+ if (priority === "MANDATORY") {
89
+ b.addLinear(terms, "<=", shifts);
90
+ } else {
91
+ b.addSoftLinear(terms, "<=", shifts, priorityToPenalty(priority));
92
+ }
93
+ }
94
+ }
95
+ },
96
+ };
97
+ }
98
+
99
+ function resolveEmployees(scope: RuleScope, employees: SchedulingEmployee[]): SchedulingEmployee[] {
100
+ const entity = scope.entity;
101
+ switch (entity.type) {
102
+ case "employees":
103
+ return employees.filter((e) => entity.employeeIds.includes(e.id));
104
+ case "roles":
105
+ return employees.filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)));
106
+ case "skills":
107
+ return employees.filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)));
108
+ case "global":
109
+ default:
110
+ return employees;
111
+ }
112
+ }
113
+
114
+ type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
115
+
116
+ function getDayOfWeekName(dayIndex: number): DayName {
117
+ const names: Record<number, DayName> = {
118
+ 0: "sunday",
119
+ 1: "monday",
120
+ 2: "tuesday",
121
+ 3: "wednesday",
122
+ 4: "thursday",
123
+ 5: "friday",
124
+ 6: "saturday",
125
+ };
126
+ return names[dayIndex % 7] ?? "sunday";
127
+ }
128
+
129
+ function resolveActiveDays(scope: RuleScope, allDays: string[]): string[] {
130
+ const timeScope = scope.time;
131
+
132
+ if (!timeScope) {
133
+ return allDays;
134
+ }
135
+
136
+ switch (timeScope.type) {
137
+ case "always":
138
+ return allDays;
139
+
140
+ case "dateRange": {
141
+ const start = timeScope.start;
142
+ const end = timeScope.end;
143
+ return allDays.filter((day) => day >= start && day <= end);
144
+ }
145
+
146
+ case "specificDates":
147
+ return allDays.filter((day) => timeScope.dates.includes(day));
148
+
149
+ case "dayOfWeek": {
150
+ const targetDays = new Set(timeScope.days);
151
+ return allDays.filter((day) => {
152
+ const date = parseDayString(day);
153
+ const dayName = getDayOfWeekName(date.getUTCDay());
154
+ return targetDays.has(dayName);
155
+ });
156
+ }
157
+
158
+ case "recurring": {
159
+ return allDays.filter((day) => {
160
+ const date = parseDayString(day);
161
+ const month = date.getUTCMonth() + 1;
162
+ const dayOfMonth = date.getUTCDate();
163
+
164
+ return timeScope.periods.some((period) =>
165
+ isDateInRecurringPeriod(month, dayOfMonth, period),
166
+ );
167
+ });
168
+ }
169
+
170
+ default:
171
+ return allDays;
172
+ }
173
+ }
174
+
175
+ function isDateInRecurringPeriod(
176
+ month: number,
177
+ dayOfMonth: number,
178
+ period: {
179
+ startMonth: number;
180
+ startDay: number;
181
+ endMonth: number;
182
+ endDay: number;
183
+ },
184
+ ): boolean {
185
+ const { startMonth, startDay, endMonth, endDay } = period;
186
+
187
+ if (startMonth <= endMonth) {
188
+ if (month < startMonth || month > endMonth) return false;
189
+ if (month === startMonth && dayOfMonth < startDay) return false;
190
+ if (month === endMonth && dayOfMonth > endDay) return false;
191
+ return true;
192
+ } else {
193
+ if (month > endMonth && month < startMonth) return false;
194
+ if (month === startMonth && dayOfMonth < startDay) return false;
195
+ if (month === endMonth && dayOfMonth > endDay) return false;
196
+ return true;
197
+ }
198
+ }