dabke 0.81.1 → 0.83.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 (231) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +45 -27
  3. package/dist/client.d.ts +20 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +4 -1
  6. package/dist/client.js.map +1 -1
  7. package/dist/client.types.d.ts +9 -0
  8. package/dist/client.types.d.ts.map +1 -1
  9. package/dist/client.types.js +1 -0
  10. package/dist/client.types.js.map +1 -1
  11. package/dist/cpsat/model-builder.d.ts +9 -0
  12. package/dist/cpsat/model-builder.d.ts.map +1 -1
  13. package/dist/cpsat/model-builder.js +36 -34
  14. package/dist/cpsat/model-builder.js.map +1 -1
  15. package/dist/cpsat/response.d.ts +13 -1
  16. package/dist/cpsat/response.d.ts.map +1 -1
  17. package/dist/cpsat/response.js +4 -0
  18. package/dist/cpsat/response.js.map +1 -1
  19. package/dist/cpsat/rules/cost-utils.d.ts +11 -0
  20. package/dist/cpsat/rules/cost-utils.d.ts.map +1 -0
  21. package/dist/cpsat/rules/cost-utils.js +24 -0
  22. package/dist/cpsat/rules/cost-utils.js.map +1 -0
  23. package/dist/cpsat/rules/day-cost-multiplier.d.ts.map +1 -1
  24. package/dist/cpsat/rules/day-cost-multiplier.js +3 -14
  25. package/dist/cpsat/rules/day-cost-multiplier.js.map +1 -1
  26. package/dist/cpsat/rules/day-cost-surcharge.d.ts.map +1 -1
  27. package/dist/cpsat/rules/day-cost-surcharge.js +3 -7
  28. package/dist/cpsat/rules/day-cost-surcharge.js.map +1 -1
  29. package/dist/cpsat/rules/index.d.ts +3 -0
  30. package/dist/cpsat/rules/index.d.ts.map +1 -1
  31. package/dist/cpsat/rules/index.js +3 -0
  32. package/dist/cpsat/rules/index.js.map +1 -1
  33. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -1
  34. package/dist/cpsat/rules/max-consecutive-days.js +16 -2
  35. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -1
  36. package/dist/cpsat/rules/max-days-week.d.ts +44 -0
  37. package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
  38. package/dist/cpsat/rules/max-days-week.js +95 -0
  39. package/dist/cpsat/rules/max-days-week.js.map +1 -0
  40. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -1
  41. package/dist/cpsat/rules/max-hours-day.js +15 -2
  42. package/dist/cpsat/rules/max-hours-day.js.map +1 -1
  43. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
  44. package/dist/cpsat/rules/max-hours-week.js +16 -2
  45. package/dist/cpsat/rules/max-hours-week.js.map +1 -1
  46. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
  47. package/dist/cpsat/rules/max-shifts-day.js +15 -2
  48. package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
  49. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
  50. package/dist/cpsat/rules/min-consecutive-days.js +15 -2
  51. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
  52. package/dist/cpsat/rules/min-days-week.d.ts +34 -0
  53. package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
  54. package/dist/cpsat/rules/min-days-week.js +84 -0
  55. package/dist/cpsat/rules/min-days-week.js.map +1 -0
  56. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -1
  57. package/dist/cpsat/rules/min-hours-day.js +15 -2
  58. package/dist/cpsat/rules/min-hours-day.js.map +1 -1
  59. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
  60. package/dist/cpsat/rules/min-hours-week.js +16 -2
  61. package/dist/cpsat/rules/min-hours-week.js.map +1 -1
  62. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
  63. package/dist/cpsat/rules/min-rest-between-shifts.js +72 -2
  64. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
  65. package/dist/cpsat/rules/minimize-cost.d.ts.map +1 -1
  66. package/dist/cpsat/rules/minimize-cost.js +2 -23
  67. package/dist/cpsat/rules/minimize-cost.js.map +1 -1
  68. package/dist/cpsat/rules/must-assign.d.ts +49 -0
  69. package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
  70. package/dist/cpsat/rules/must-assign.js +86 -0
  71. package/dist/cpsat/rules/must-assign.js.map +1 -0
  72. package/dist/cpsat/rules/overtime-daily-multiplier.d.ts.map +1 -1
  73. package/dist/cpsat/rules/overtime-daily-multiplier.js +1 -12
  74. package/dist/cpsat/rules/overtime-daily-multiplier.js.map +1 -1
  75. package/dist/cpsat/rules/overtime-daily-surcharge.d.ts.map +1 -1
  76. package/dist/cpsat/rules/overtime-daily-surcharge.js +1 -5
  77. package/dist/cpsat/rules/overtime-daily-surcharge.js.map +1 -1
  78. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts +5 -1
  79. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts.map +1 -1
  80. package/dist/cpsat/rules/overtime-tiered-multiplier.js +1 -12
  81. package/dist/cpsat/rules/overtime-tiered-multiplier.js.map +1 -1
  82. package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts.map +1 -1
  83. package/dist/cpsat/rules/overtime-weekly-multiplier.js +1 -12
  84. package/dist/cpsat/rules/overtime-weekly-multiplier.js.map +1 -1
  85. package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts.map +1 -1
  86. package/dist/cpsat/rules/overtime-weekly-surcharge.js +1 -5
  87. package/dist/cpsat/rules/overtime-weekly-surcharge.js.map +1 -1
  88. package/dist/cpsat/rules/registry.d.ts +28 -2
  89. package/dist/cpsat/rules/registry.d.ts.map +1 -1
  90. package/dist/cpsat/rules/registry.js +4 -1
  91. package/dist/cpsat/rules/registry.js.map +1 -1
  92. package/dist/cpsat/rules/resolver.js +2 -2
  93. package/dist/cpsat/rules/resolver.js.map +1 -1
  94. package/dist/cpsat/rules/rules.types.d.ts +3 -0
  95. package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
  96. package/dist/cpsat/rules/scope.types.d.ts +18 -1
  97. package/dist/cpsat/rules/scope.types.d.ts.map +1 -1
  98. package/dist/cpsat/rules/scope.types.js +59 -16
  99. package/dist/cpsat/rules/scope.types.js.map +1 -1
  100. package/dist/cpsat/rules/time-cost-surcharge.d.ts.map +1 -1
  101. package/dist/cpsat/rules/time-cost-surcharge.js +2 -1
  102. package/dist/cpsat/rules/time-cost-surcharge.js.map +1 -1
  103. package/dist/cpsat/rules/time-off.d.ts.map +1 -1
  104. package/dist/cpsat/rules/time-off.js +6 -3
  105. package/dist/cpsat/rules/time-off.js.map +1 -1
  106. package/dist/cpsat/semantic-time.d.ts +44 -42
  107. package/dist/cpsat/semantic-time.d.ts.map +1 -1
  108. package/dist/cpsat/semantic-time.js +64 -46
  109. package/dist/cpsat/semantic-time.js.map +1 -1
  110. package/dist/cpsat/types.d.ts +37 -27
  111. package/dist/cpsat/types.d.ts.map +1 -1
  112. package/dist/cpsat/utils.d.ts.map +1 -1
  113. package/dist/cpsat/utils.js +7 -12
  114. package/dist/cpsat/utils.js.map +1 -1
  115. package/dist/cpsat/validation-reporter.d.ts +10 -7
  116. package/dist/cpsat/validation-reporter.d.ts.map +1 -1
  117. package/dist/cpsat/validation-reporter.js +44 -72
  118. package/dist/cpsat/validation-reporter.js.map +1 -1
  119. package/dist/cpsat/validation.types.d.ts +54 -44
  120. package/dist/cpsat/validation.types.d.ts.map +1 -1
  121. package/dist/cpsat/validation.types.js +15 -10
  122. package/dist/cpsat/validation.types.js.map +1 -1
  123. package/dist/datetime.utils.d.ts +3 -203
  124. package/dist/datetime.utils.d.ts.map +1 -1
  125. package/dist/datetime.utils.js +1 -288
  126. package/dist/datetime.utils.js.map +1 -1
  127. package/dist/index.d.ts +14 -83
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +11 -83
  130. package/dist/index.js.map +1 -1
  131. package/dist/schedule/cost.d.ts +204 -0
  132. package/dist/schedule/cost.d.ts.map +1 -0
  133. package/dist/schedule/cost.js +187 -0
  134. package/dist/schedule/cost.js.map +1 -0
  135. package/dist/schedule/coverage.d.ts +85 -0
  136. package/dist/schedule/coverage.d.ts.map +1 -0
  137. package/dist/schedule/coverage.js +33 -0
  138. package/dist/schedule/coverage.js.map +1 -0
  139. package/dist/schedule/definition.d.ts +227 -0
  140. package/dist/schedule/definition.d.ts.map +1 -0
  141. package/dist/schedule/definition.js +659 -0
  142. package/dist/schedule/definition.js.map +1 -0
  143. package/dist/schedule/index.d.ts +67 -0
  144. package/dist/schedule/index.d.ts.map +1 -0
  145. package/dist/schedule/index.js +69 -0
  146. package/dist/schedule/index.js.map +1 -0
  147. package/dist/schedule/rules.d.ts +353 -0
  148. package/dist/schedule/rules.d.ts.map +1 -0
  149. package/dist/schedule/rules.js +352 -0
  150. package/dist/schedule/rules.js.map +1 -0
  151. package/dist/schedule/shift-patterns.d.ts +34 -0
  152. package/dist/schedule/shift-patterns.d.ts.map +1 -0
  153. package/dist/schedule/shift-patterns.js +41 -0
  154. package/dist/schedule/shift-patterns.js.map +1 -0
  155. package/dist/schedule/time-periods.d.ts +69 -0
  156. package/dist/schedule/time-periods.d.ts.map +1 -0
  157. package/dist/schedule/time-periods.js +91 -0
  158. package/dist/schedule/time-periods.js.map +1 -0
  159. package/dist/types.d.ts +14 -78
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/types.js.map +1 -1
  162. package/package.json +4 -9
  163. package/solver/src/solver/app.py +1 -1
  164. package/solver/src/solver/solver.py +7 -4
  165. package/src/client.ts +6 -8
  166. package/src/client.types.ts +9 -0
  167. package/src/cpsat/model-builder.ts +44 -35
  168. package/src/cpsat/response.ts +13 -1
  169. package/src/cpsat/rules/cost-utils.ts +25 -0
  170. package/src/cpsat/rules/day-cost-multiplier.ts +3 -14
  171. package/src/cpsat/rules/day-cost-surcharge.ts +3 -8
  172. package/src/cpsat/rules/index.ts +3 -0
  173. package/src/cpsat/rules/max-consecutive-days.ts +17 -0
  174. package/src/cpsat/rules/max-days-week.ts +143 -0
  175. package/src/cpsat/rules/max-hours-day.ts +21 -1
  176. package/src/cpsat/rules/max-hours-week.ts +22 -1
  177. package/src/cpsat/rules/max-shifts-day.ts +21 -1
  178. package/src/cpsat/rules/min-consecutive-days.ts +16 -1
  179. package/src/cpsat/rules/min-days-week.ts +120 -0
  180. package/src/cpsat/rules/min-hours-day.ts +16 -1
  181. package/src/cpsat/rules/min-hours-week.ts +17 -1
  182. package/src/cpsat/rules/min-rest-between-shifts.ts +92 -2
  183. package/src/cpsat/rules/minimize-cost.ts +2 -29
  184. package/src/cpsat/rules/must-assign.ts +108 -0
  185. package/src/cpsat/rules/overtime-daily-multiplier.ts +1 -12
  186. package/src/cpsat/rules/overtime-daily-surcharge.ts +1 -6
  187. package/src/cpsat/rules/overtime-tiered-multiplier.ts +6 -13
  188. package/src/cpsat/rules/overtime-weekly-multiplier.ts +1 -12
  189. package/src/cpsat/rules/overtime-weekly-surcharge.ts +1 -6
  190. package/src/cpsat/rules/registry.ts +8 -2
  191. package/src/cpsat/rules/resolver.ts +2 -2
  192. package/src/cpsat/rules/rules.types.ts +3 -0
  193. package/src/cpsat/rules/scope.types.ts +73 -20
  194. package/src/cpsat/rules/time-cost-surcharge.ts +2 -1
  195. package/src/cpsat/rules/time-off.ts +6 -2
  196. package/src/cpsat/semantic-time.ts +115 -91
  197. package/src/cpsat/types.ts +37 -27
  198. package/src/cpsat/utils.ts +8 -12
  199. package/src/cpsat/validation-reporter.ts +51 -82
  200. package/src/cpsat/validation.types.ts +72 -47
  201. package/src/datetime.utils.ts +3 -334
  202. package/src/index.ts +35 -107
  203. package/src/schedule/cost.ts +242 -0
  204. package/src/schedule/coverage.ts +135 -0
  205. package/src/schedule/definition.ts +958 -0
  206. package/src/schedule/index.ts +112 -0
  207. package/src/schedule/rules.ts +529 -0
  208. package/src/schedule/shift-patterns.ts +46 -0
  209. package/src/schedule/time-periods.ts +110 -0
  210. package/src/types.ts +14 -88
  211. package/dist/errors.d.ts +0 -12
  212. package/dist/errors.d.ts.map +0 -1
  213. package/dist/errors.js +0 -17
  214. package/dist/errors.js.map +0 -1
  215. package/dist/llms.d.ts +0 -2
  216. package/dist/llms.d.ts.map +0 -1
  217. package/dist/llms.js +0 -3
  218. package/dist/llms.js.map +0 -1
  219. package/dist/schedule.d.ts +0 -724
  220. package/dist/schedule.d.ts.map +0 -1
  221. package/dist/schedule.js +0 -899
  222. package/dist/schedule.js.map +0 -1
  223. package/dist/validation.d.ts +0 -105
  224. package/dist/validation.d.ts.map +0 -1
  225. package/dist/validation.js +0 -130
  226. package/dist/validation.js.map +0 -1
  227. package/llms.txt +0 -925
  228. package/src/errors.ts +0 -17
  229. package/src/llms.ts +0 -3
  230. package/src/schedule.ts +0 -1419
  231. package/src/validation.ts +0 -188
package/src/schedule.ts DELETED
@@ -1,1419 +0,0 @@
1
- /**
2
- * High-level schedule definition API.
3
- *
4
- * Small, composable factory functions that produce a complete scheduling
5
- * configuration. Designed for LLM code generation: each concept is a single
6
- * function call with per-call type safety.
7
- *
8
- * @example
9
- * ```typescript
10
- * import {
11
- * defineSchedule, t, time, cover, shift,
12
- * maxHoursPerDay, maxHoursPerWeek, minRestBetweenShifts,
13
- * weekdays, weekend,
14
- * } from "dabke";
15
- *
16
- * export default defineSchedule({
17
- * roles: ["cashier", "floor_lead", "stocker"],
18
- * skills: ["keyholder"],
19
- *
20
- * times: {
21
- * opening: time({ startTime: t(8), endTime: t(10) }),
22
- * peak_hours: time(
23
- * { startTime: t(11), endTime: t(14) },
24
- * { startTime: t(10), endTime: t(15), dayOfWeek: weekend },
25
- * ),
26
- * closing: time({ startTime: t(20), endTime: t(22) }),
27
- * },
28
- *
29
- * coverage: [
30
- * cover("opening", "keyholder", 1),
31
- * cover("peak_hours", "cashier", 3, { dayOfWeek: weekdays }),
32
- * cover("peak_hours", "cashier", 5, { dayOfWeek: weekend }),
33
- * cover("closing", "floor_lead", 1),
34
- * ],
35
- *
36
- * shiftPatterns: [
37
- * shift("morning", t(8), t(14)),
38
- * shift("afternoon", t(14), t(22)),
39
- * ],
40
- *
41
- * rules: [
42
- * maxHoursPerDay(10),
43
- * maxHoursPerWeek(48),
44
- * minRestBetweenShifts(10),
45
- * ],
46
- * });
47
- * ```
48
- *
49
- * @module
50
- */
51
-
52
- import type { DayOfWeek, SchedulingPeriod, TimeOfDay } from "./types.js";
53
- import type {
54
- SemanticTimeDef,
55
- SemanticTimeVariant,
56
- SemanticTimeEntry,
57
- MixedCoverageRequirement,
58
- CoverageVariant,
59
- } from "./cpsat/semantic-time.js";
60
-
61
- export type { CoverageVariant } from "./cpsat/semantic-time.js";
62
- import { defineSemanticTimes } from "./cpsat/semantic-time.js";
63
- import { resolveDaysFromPeriod } from "./datetime.utils.js";
64
- import type { ModelBuilderConfig } from "./cpsat/model-builder.js";
65
- import type { SchedulingMember, ShiftPattern, Priority } from "./cpsat/types.js";
66
- import type { CpsatRuleName, CpsatRuleConfigEntry } from "./cpsat/rules/rules.types.js";
67
- import type { RecurringPeriod } from "./cpsat/rules/scope.types.js";
68
- import type { OvertimeTier } from "./cpsat/rules/overtime-tiered-multiplier.js";
69
-
70
- // ============================================================================
71
- // Primitives
72
- // ============================================================================
73
-
74
- /**
75
- * Creates a {@link TimeOfDay} value.
76
- *
77
- * @param hours - Hour component (0-23)
78
- * @param minutes - Minute component (0-59)
79
- *
80
- * @example Hours only
81
- * ```ts
82
- * t(9) // { hours: 9, minutes: 0 }
83
- * ```
84
- *
85
- * @example Hours and minutes
86
- * ```ts
87
- * t(17, 30) // { hours: 17, minutes: 30 }
88
- * ```
89
- */
90
- export function t(hours: number, minutes = 0): TimeOfDay {
91
- return { hours, minutes };
92
- }
93
-
94
- /** Monday through Friday. */
95
- export const weekdays: readonly DayOfWeek[] = [
96
- "monday",
97
- "tuesday",
98
- "wednesday",
99
- "thursday",
100
- "friday",
101
- ] as const;
102
-
103
- /** Saturday and Sunday. */
104
- export const weekend: readonly DayOfWeek[] = ["saturday", "sunday"] as const;
105
-
106
- // ============================================================================
107
- // Semantic Times
108
- // ============================================================================
109
-
110
- /**
111
- * Defines a named time window.
112
- *
113
- * @remarks
114
- * A semantic time is any recurring period you need to reference:
115
- * service hours, delivery windows, peak periods, weekly events. Times
116
- * may overlap (e.g., "dinner" 18:00-22:00 and "happy_hour"
117
- * 17:30-18:30, or "lunch" 12:00-14:00 with "peak_lunch"
118
- * 13:00-13:30). Coverage and rules reference these names; each
119
- * generates independent constraints.
120
- *
121
- * Every argument is a {@link SemanticTimeVariant} with `startTime`/`endTime`
122
- * and optional `dayOfWeek`/`dates` scoping. An entry without scoping is the
123
- * default (applies when no scoped entry matches). At most one default is
124
- * allowed. If no default, the time only exists on the scoped days.
125
- *
126
- * Resolution precedence: `dates` > `dayOfWeek` > default.
127
- *
128
- * @example Every day
129
- * ```typescript
130
- * day_shift: time({ startTime: t(7), endTime: t(15) }),
131
- * ```
132
- *
133
- * @example Default with weekend variant
134
- * ```typescript
135
- * peak_hours: time(
136
- * { startTime: t(9), endTime: t(17) },
137
- * { startTime: t(10), endTime: t(15), dayOfWeek: weekend },
138
- * ),
139
- * ```
140
- *
141
- * @example No default (specific days only)
142
- * ```typescript
143
- * happy_hour: time(
144
- * { startTime: t(16), endTime: t(18), dayOfWeek: ["monday", "tuesday"] },
145
- * { startTime: t(17), endTime: t(19), dayOfWeek: ["friday"] },
146
- * ),
147
- * ```
148
- */
149
- export function time(
150
- ...entries: [SemanticTimeVariant, ...SemanticTimeVariant[]]
151
- ): SemanticTimeEntry {
152
- // Validate: at most one default (no dayOfWeek and no dates)
153
- const defaults = entries.filter((e) => !e.dayOfWeek && !e.dates);
154
- if (defaults.length > 1) {
155
- throw new Error(
156
- "time() accepts at most one default entry (without dayOfWeek or dates). " +
157
- `Found ${defaults.length} default entries.`,
158
- );
159
- }
160
-
161
- // Single entry without scoping: simple SemanticTimeDef
162
- if (entries.length === 1 && !entries[0].dayOfWeek && !entries[0].dates) {
163
- return {
164
- startTime: entries[0].startTime,
165
- endTime: entries[0].endTime,
166
- } satisfies SemanticTimeDef;
167
- }
168
-
169
- // Multiple entries or scoped entries: pass through directly
170
- return entries.map((entry): SemanticTimeVariant => {
171
- const variant: SemanticTimeVariant = {
172
- startTime: entry.startTime,
173
- endTime: entry.endTime,
174
- };
175
- if (entry.dayOfWeek) variant.dayOfWeek = entry.dayOfWeek;
176
- if (entry.dates) variant.dates = entry.dates;
177
- return variant;
178
- });
179
- }
180
-
181
- // ============================================================================
182
- // Coverage
183
- // ============================================================================
184
-
185
- /**
186
- * Options for a {@link cover} call.
187
- *
188
- * @remarks
189
- * Day/date scoping controls which days this coverage entry applies to.
190
- * An entry without `dayOfWeek` or `dates` applies every day in the
191
- * scheduling period.
192
- */
193
- export interface CoverageOptions {
194
- /** Additional skill filter (AND logic with the target role). */
195
- skills?: [string, ...string[]];
196
- /** Restrict to specific days of the week. */
197
- dayOfWeek?: readonly DayOfWeek[];
198
- /** Restrict to specific dates (YYYY-MM-DD). */
199
- dates?: string[];
200
- /** Defaults to `"MANDATORY"`. */
201
- priority?: Priority;
202
- }
203
-
204
- /**
205
- * A coverage entry returned by {@link cover}.
206
- *
207
- * @remarks
208
- * Carries the semantic time name and target type information for
209
- * compile-time validation by {@link defineSchedule}. This is an opaque
210
- * token; pass it directly into the `coverage` array.
211
- */
212
- export interface CoverageEntry<T extends string = string, R extends string = string> {
213
- /** @internal */ readonly _type: "coverage";
214
- /** @internal */ readonly timeName: T;
215
- /** @internal */ readonly target: R | R[];
216
- /** @internal */ readonly count: number;
217
- /** @internal */ readonly options: CoverageOptions;
218
- /** @internal When present, this entry uses variant-based resolution. */
219
- readonly variants?: readonly CoverageVariant[];
220
- }
221
-
222
- /**
223
- * Defines a staffing requirement for a semantic time period.
224
- *
225
- * @remarks
226
- * Two call forms are supported:
227
- *
228
- * **Simple form** `cover(time, target, count, opts?)` creates a single
229
- * constraint. Use `dayOfWeek`/`dates` in `opts` to restrict which days
230
- * it applies to.
231
- *
232
- * **Variant form** `cover(time, target, ...variants)` accepts one or more
233
- * {@link CoverageVariant} entries with day-specific counts. For each
234
- * scheduling day, exactly one variant is selected using the same
235
- * precedence as {@link time}: `dates` > `dayOfWeek` > default (unscoped).
236
- * At most one variant may be unscoped (the default). Days with no matching
237
- * variant produce no coverage. See {@link CoverageVariant} for the entry
238
- * shape.
239
- *
240
- * **Target resolution.** The `target` parameter is resolved against declared
241
- * `roles` and `skills`:
242
- *
243
- * - Single string: matched against roles first, then skills.
244
- * - Array of strings: OR logic (any of the listed roles).
245
- * - With `skills` option (simple form only): role AND skill(s) filter.
246
- *
247
- * @param timeName - Name of a declared semantic time
248
- * @param target - Role name, skill name, or array of role names (OR)
249
- * @param count - Number of people needed (simple form)
250
- * @param opts - See {@link CoverageOptions} (simple form)
251
- *
252
- * @example Basic role coverage
253
- * ```ts
254
- * cover("day_shift", "nurse", 3)
255
- * ```
256
- *
257
- * @example OR logic (any of the listed roles)
258
- * ```ts
259
- * cover("day_shift", ["manager", "team_lead"], 1)
260
- * ```
261
- *
262
- * @example Skill-based coverage
263
- * ```ts
264
- * cover("night_shift", "keyholder", 1)
265
- * ```
266
- *
267
- * @example Role with skill filter (role AND skill)
268
- * ```ts
269
- * cover("day_shift", "nurse", 1, { skills: ["charge_nurse"] })
270
- * ```
271
- *
272
- * @example Day-of-week scoping (simple form)
273
- * ```ts
274
- * cover("peak_hours", "cashier", 3, { dayOfWeek: weekdays }),
275
- * cover("peak_hours", "cashier", 5, { dayOfWeek: weekend }),
276
- * ```
277
- *
278
- * @example Default with date override (variant form)
279
- * ```ts
280
- * cover("peak_hours", "agent",
281
- * { count: 4 },
282
- * { count: 2, dates: ["2025-12-24"] },
283
- * )
284
- * ```
285
- *
286
- * @example Weekday vs weekend with holiday override (variant form)
287
- * ```ts
288
- * cover("peak_hours", "agent",
289
- * { count: 3, dayOfWeek: weekdays },
290
- * { count: 5, dayOfWeek: weekend },
291
- * { count: 8, dates: ["2025-12-31"] },
292
- * )
293
- * ```
294
- */
295
- export function cover<T extends string, R extends string>(
296
- timeName: T,
297
- target: R | [R, ...R[]],
298
- count: number,
299
- opts?: CoverageOptions,
300
- ): CoverageEntry<T, R>;
301
- export function cover<T extends string, R extends string>(
302
- timeName: T,
303
- target: R | [R, ...R[]],
304
- ...variants: [CoverageVariant, ...CoverageVariant[]]
305
- ): CoverageEntry<T, R>;
306
- export function cover<T extends string, R extends string>(
307
- timeName: T,
308
- target: R | [R, ...R[]],
309
- countOrFirstVariant: number | CoverageVariant,
310
- ...rest: unknown[]
311
- ): CoverageEntry<T, R> {
312
- if (typeof countOrFirstVariant === "number") {
313
- // Simple form: cover(time, target, count, opts?)
314
- return {
315
- _type: "coverage",
316
- timeName,
317
- target,
318
- count: countOrFirstVariant,
319
- options: (rest[0] as CoverageOptions | undefined) ?? {},
320
- };
321
- }
322
-
323
- // Variant form: cover(time, target, ...variants)
324
- const variants = [countOrFirstVariant, ...(rest as CoverageVariant[])];
325
-
326
- const defaults = variants.filter((v) => !v.dayOfWeek && !v.dates);
327
- if (defaults.length > 1) {
328
- throw new Error(
329
- "cover() accepts at most one default variant (without dayOfWeek or dates). " +
330
- `Found ${defaults.length} default variants.`,
331
- );
332
- }
333
-
334
- return {
335
- _type: "coverage",
336
- timeName,
337
- target,
338
- count: 0,
339
- options: {},
340
- variants,
341
- };
342
- }
343
-
344
- // ============================================================================
345
- // Shift Patterns
346
- // ============================================================================
347
-
348
- /**
349
- * Creates a {@link ShiftPattern} (time slot template).
350
- *
351
- * @remarks
352
- * Shift patterns define when people can work: the concrete time slots
353
- * the solver may assign members to. Each pattern repeats across all
354
- * scheduling days unless filtered by `dayOfWeek` or `roles`.
355
- *
356
- * @example
357
- * ```typescript
358
- * shift("early", t(6), t(14)),
359
- * shift("day", t(9), t(17)),
360
- * shift("night", t(22), t(6), { roles: ["nurse", "doctor"] }),
361
- * ```
362
- */
363
- export function shift(
364
- id: string,
365
- startTime: TimeOfDay,
366
- endTime: TimeOfDay,
367
- opts?: Pick<ShiftPattern, "roles" | "dayOfWeek" | "locationId">,
368
- ): ShiftPattern {
369
- const pattern: ShiftPattern = { id, startTime, endTime };
370
- if (opts?.roles) pattern.roles = opts.roles;
371
- if (opts?.dayOfWeek) pattern.dayOfWeek = opts.dayOfWeek as DayOfWeek[];
372
- if (opts?.locationId) pattern.locationId = opts.locationId;
373
- return pattern;
374
- }
375
-
376
- // ============================================================================
377
- // Rules
378
- // ============================================================================
379
-
380
- /**
381
- * Scoping options shared by most rule functions.
382
- *
383
- * @remarks
384
- * Each rule function returns an opaque {@link RuleEntry} for the `rules`
385
- * array. Most accept a `RuleOptions` parameter for scoping and priority.
386
- *
387
- * **Entity scoping.** `appliesTo` targets a role name, skill name, or
388
- * member ID. It is resolved against declared roles first, then skills,
389
- * then runtime member IDs. The namespaces are guaranteed disjoint by
390
- * validation. Unscoped rules apply to all members.
391
- *
392
- * **Time scoping.** `dayOfWeek`, `dateRange`, `dates`, and
393
- * `recurringPeriods` narrow when the rule is active. Unscoped rules
394
- * apply to every day in the scheduling period.
395
- *
396
- * **Priority.** Defaults to `MANDATORY` (hard constraint the solver
397
- * must satisfy). Use `LOW`, `MEDIUM`, or `HIGH` for soft preferences
398
- * the solver may violate when necessary.
399
- */
400
- export interface RuleOptions {
401
- /** Who this rule applies to (role name, skill name, or member ID). */
402
- appliesTo?: string | string[];
403
- /** Restrict to specific days of the week. */
404
- dayOfWeek?: readonly DayOfWeek[];
405
- /** Restrict to a date range. */
406
- dateRange?: { start: string; end: string };
407
- /** Restrict to specific dates (YYYY-MM-DD). */
408
- dates?: string[];
409
- /** Restrict to recurring calendar periods. */
410
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
411
- /** Defaults to `"MANDATORY"`. */
412
- priority?: Priority;
413
- }
414
-
415
- /**
416
- * Options for rules that support entity scoping only (no time scoping).
417
- *
418
- * @remarks
419
- * Used by rules whose semantics are inherently per-day or per-week
420
- * (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
421
- * be meaningfully restricted to a date range or day of week.
422
- */
423
- export interface EntityOnlyRuleOptions {
424
- /** Who this rule applies to (role name, skill name, or member ID). */
425
- appliesTo?: string | string[];
426
- /** Defaults to `"MANDATORY"`. */
427
- priority?: Priority;
428
- }
429
-
430
- /**
431
- * Options for {@link timeOff}.
432
- *
433
- * @remarks
434
- * At least one time scoping field is required (`dayOfWeek`, `dateRange`,
435
- * `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
436
- * of a day.
437
- */
438
- export interface TimeOffOptions {
439
- /** Who this rule applies to (role name, skill name, or member ID). */
440
- appliesTo?: string | string[];
441
- /** Off from this time until end of day. */
442
- from?: TimeOfDay;
443
- /** Off from start of day until this time. */
444
- until?: TimeOfDay;
445
- /** Restrict to specific days of the week. */
446
- dayOfWeek?: readonly DayOfWeek[];
447
- /** Restrict to a date range. */
448
- dateRange?: { start: string; end: string };
449
- /** Restrict to specific dates (YYYY-MM-DD). */
450
- dates?: string[];
451
- /** Restrict to recurring calendar periods. */
452
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
453
- /** Defaults to `"MANDATORY"`. */
454
- priority?: Priority;
455
- }
456
-
457
- /** Options for {@link assignTogether}. */
458
- export interface AssignTogetherOptions {
459
- /** Defaults to `"MANDATORY"`. */
460
- priority?: Priority;
461
- }
462
-
463
- /**
464
- * Options for cost rules.
465
- *
466
- * Cost rules are objective terms, not constraints. The `priority` field from
467
- * {@link RuleOptions} does not apply.
468
- */
469
- export interface CostRuleOptions {
470
- /** Who this rule applies to (role name, skill name, or member ID). */
471
- appliesTo?: string | string[];
472
- /** Restrict to specific days of the week. */
473
- dayOfWeek?: DayOfWeek[];
474
- /** Restrict to a date range. */
475
- dateRange?: { start: string; end: string };
476
- /** Restrict to specific dates (YYYY-MM-DD). */
477
- dates?: string[];
478
- /** Restrict to recurring calendar periods. */
479
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
480
- }
481
-
482
- // Internal rule entry type
483
- interface RuleEntryBase {
484
- readonly _type: "rule";
485
- readonly _rule: CpsatRuleName;
486
- }
487
-
488
- /**
489
- * An opaque rule entry returned by rule functions.
490
- *
491
- * @remarks
492
- * Pass these directly into the `rules` array of {@link ScheduleConfig} or
493
- * the `runtimeRules` array of {@link RuntimeArgs}. The internal fields are
494
- * resolved during {@link ScheduleDefinition.createSchedulerConfig}.
495
- */
496
- export type RuleEntry = RuleEntryBase & Record<string, unknown>;
497
-
498
- function makeRule(rule: CpsatRuleName, fields: Record<string, unknown>): RuleEntry {
499
- return { _type: "rule", _rule: rule, ...fields };
500
- }
501
-
502
- /**
503
- * Limits how many hours a person can work in a single day.
504
- *
505
- * @example Global limit
506
- * ```ts
507
- * maxHoursPerDay(10)
508
- * ```
509
- *
510
- * @example Scoped to a role
511
- * ```ts
512
- * maxHoursPerDay(6, { appliesTo: "student" })
513
- * ```
514
- */
515
- export function maxHoursPerDay(hours: number, opts?: RuleOptions): RuleEntry {
516
- return makeRule("max-hours-day", { hours, ...opts });
517
- }
518
-
519
- /**
520
- * Caps total hours a person can work within each scheduling week.
521
- *
522
- * @example Global cap
523
- * ```ts
524
- * maxHoursPerWeek(48)
525
- * ```
526
- *
527
- * @example Part-time cap for a skill group
528
- * ```ts
529
- * maxHoursPerWeek(20, { appliesTo: "part_time" })
530
- * ```
531
- */
532
- export function maxHoursPerWeek(hours: number, opts?: RuleOptions): RuleEntry {
533
- return makeRule("max-hours-week", { hours, ...opts });
534
- }
535
-
536
- /**
537
- * Ensures a person works at least a minimum number of hours per day when assigned.
538
- *
539
- * @example
540
- * ```ts
541
- * minHoursPerDay(4)
542
- * ```
543
- */
544
- export function minHoursPerDay(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
545
- return makeRule("min-hours-day", { hours, ...opts });
546
- }
547
-
548
- /**
549
- * Enforces a minimum total number of hours per scheduling week.
550
- *
551
- * @example Guaranteed minimum for full-time members
552
- * ```ts
553
- * minHoursPerWeek(30, { appliesTo: "full_time" })
554
- * ```
555
- */
556
- export function minHoursPerWeek(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
557
- return makeRule("min-hours-week", { hours, ...opts });
558
- }
559
-
560
- /**
561
- * Limits how many shifts a person can work in a single day.
562
- *
563
- * @example One shift per day
564
- * ```ts
565
- * maxShiftsPerDay(1)
566
- * ```
567
- */
568
- export function maxShiftsPerDay(shifts: number, opts?: RuleOptions): RuleEntry {
569
- return makeRule("max-shifts-day", { shifts, ...opts });
570
- }
571
-
572
- /**
573
- * Limits how many consecutive days a person can be assigned.
574
- *
575
- * @example Five-day work week limit
576
- * ```ts
577
- * maxConsecutiveDays(5)
578
- * ```
579
- */
580
- export function maxConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
581
- return makeRule("max-consecutive-days", { days, ...opts });
582
- }
583
-
584
- /**
585
- * Requires a minimum stretch of consecutive working days once assigned.
586
- *
587
- * @example
588
- * ```ts
589
- * minConsecutiveDays(2)
590
- * ```
591
- */
592
- export function minConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
593
- return makeRule("min-consecutive-days", { days, ...opts });
594
- }
595
-
596
- /**
597
- * Enforces a minimum rest period between any two shifts a person works.
598
- *
599
- * @example EU Working Time Directive (11 hours)
600
- * ```ts
601
- * minRestBetweenShifts(11)
602
- * ```
603
- */
604
- export function minRestBetweenShifts(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
605
- return makeRule("min-rest-between-shifts", { hours, ...opts });
606
- }
607
-
608
- /**
609
- * Adds objective weight to prefer or avoid assigning team members.
610
- *
611
- * @param level - `"high"` to prefer assigning, `"low"` to avoid
612
- * @param opts - Entity and time scoping (no priority; preference is the priority mechanism)
613
- *
614
- * @example Prefer assigning full-time staff
615
- * ```ts
616
- * preference("high", { appliesTo: "full_time" })
617
- * ```
618
- *
619
- * @example Avoid assigning a specific member on weekends
620
- * ```ts
621
- * preference("low", { appliesTo: "alice", dayOfWeek: weekend })
622
- * ```
623
- */
624
- export function preference(level: "high" | "low", opts?: Omit<RuleOptions, "priority">): RuleEntry {
625
- return makeRule("assignment-priority", { preference: level, ...opts });
626
- }
627
-
628
- /**
629
- * Prefers assigning a person to shift patterns at a specific location.
630
- *
631
- * @example
632
- * ```ts
633
- * preferLocation("north_wing", { appliesTo: "alice" })
634
- * ```
635
- */
636
- export function preferLocation(locationId: string, opts?: EntityOnlyRuleOptions): RuleEntry {
637
- return makeRule("location-preference", { locationId, ...opts });
638
- }
639
-
640
- /**
641
- * Tells the solver to minimize total labor cost.
642
- *
643
- * @remarks
644
- * Without this rule, cost modifiers only affect post-solve calculation.
645
- * When present, the solver actively prefers cheaper assignments.
646
- *
647
- * For hourly members, penalizes each assignment proportionally to cost.
648
- * For salaried members, adds a fixed weekly salary cost when they have
649
- * any assignment that week (zero marginal cost up to contracted hours).
650
- *
651
- * @example
652
- * ```ts
653
- * minimizeCost()
654
- * ```
655
- */
656
- export function minimizeCost(opts?: CostRuleOptions): RuleEntry {
657
- return makeRule("minimize-cost", { ...opts });
658
- }
659
-
660
- /**
661
- * Multiplies the base rate for assignments on specified days.
662
- *
663
- * @remarks
664
- * The base cost (1x) is already counted by {@link minimizeCost};
665
- * this rule adds only the extra portion above 1x.
666
- *
667
- * @example Weekend multiplier
668
- * ```typescript
669
- * dayMultiplier(1.5, { dayOfWeek: weekend })
670
- * ```
671
- */
672
- export function dayMultiplier(factor: number, opts?: CostRuleOptions): RuleEntry {
673
- return makeRule("day-cost-multiplier", { factor, ...opts });
674
- }
675
-
676
- /**
677
- * Adds a flat extra amount per hour for assignments on specified days.
678
- *
679
- * @remarks
680
- * The surcharge is independent of the member's base rate.
681
- *
682
- * @example Weekend surcharge
683
- * ```typescript
684
- * daySurcharge(500, { dayOfWeek: weekend })
685
- * ```
686
- */
687
- export function daySurcharge(amountPerHour: number, opts?: CostRuleOptions): RuleEntry {
688
- return makeRule("day-cost-surcharge", { amountPerHour, ...opts });
689
- }
690
-
691
- /**
692
- * Adds a flat surcharge per hour for the portion of a shift that overlaps a time-of-day window.
693
- *
694
- * @remarks
695
- * The window supports overnight spans (e.g., 22:00-06:00). The surcharge
696
- * is independent of the member's base rate.
697
- *
698
- * @param amountPerHour - Flat surcharge per hour in smallest currency unit
699
- * @param window - Time-of-day window
700
- * @param opts - Entity and time scoping
701
- *
702
- * @example Night differential
703
- * ```typescript
704
- * timeSurcharge(200, { from: t(22), until: t(6) })
705
- * ```
706
- */
707
- export function timeSurcharge(
708
- amountPerHour: number,
709
- window: { from: TimeOfDay; until: TimeOfDay },
710
- opts?: CostRuleOptions,
711
- ): RuleEntry {
712
- return makeRule("time-cost-surcharge", { amountPerHour, window, ...opts });
713
- }
714
-
715
- /**
716
- * Applies a multiplier to hours beyond a weekly threshold.
717
- *
718
- * @remarks
719
- * Only the extra portion above 1x is added (the base cost is already
720
- * counted by {@link minimizeCost}).
721
- *
722
- * @example
723
- * ```typescript
724
- * overtimeMultiplier({ after: 40, factor: 1.5 })
725
- * ```
726
- */
727
- export function overtimeMultiplier(
728
- opts: { after: number; factor: number } & CostRuleOptions,
729
- ): RuleEntry {
730
- return makeRule("overtime-weekly-multiplier", { ...opts });
731
- }
732
-
733
- /**
734
- * Adds a flat surcharge per hour beyond a weekly threshold.
735
- *
736
- * @remarks
737
- * The surcharge is independent of the member's base rate.
738
- *
739
- * @example
740
- * ```typescript
741
- * overtimeSurcharge({ after: 40, amount: 1000 })
742
- * ```
743
- */
744
- export function overtimeSurcharge(
745
- opts: { after: number; amount: number } & CostRuleOptions,
746
- ): RuleEntry {
747
- return makeRule("overtime-weekly-surcharge", { ...opts });
748
- }
749
-
750
- /**
751
- * Applies a multiplier to hours beyond a daily threshold.
752
- *
753
- * @remarks
754
- * Only the extra portion above 1x is added (the base cost is already
755
- * counted by {@link minimizeCost}).
756
- *
757
- * @example
758
- * ```typescript
759
- * dailyOvertimeMultiplier({ after: 8, factor: 1.5 })
760
- * ```
761
- */
762
- export function dailyOvertimeMultiplier(
763
- opts: { after: number; factor: number } & CostRuleOptions,
764
- ): RuleEntry {
765
- return makeRule("overtime-daily-multiplier", { ...opts });
766
- }
767
-
768
- /**
769
- * Adds a flat surcharge per hour beyond a daily threshold.
770
- *
771
- * @remarks
772
- * The surcharge is independent of the member's base rate.
773
- *
774
- * @example
775
- * ```typescript
776
- * dailyOvertimeSurcharge({ after: 8, amount: 500 })
777
- * ```
778
- */
779
- export function dailyOvertimeSurcharge(
780
- opts: { after: number; amount: number } & CostRuleOptions,
781
- ): RuleEntry {
782
- return makeRule("overtime-daily-surcharge", { ...opts });
783
- }
784
-
785
- /**
786
- * Applies multiple overtime thresholds with increasing multipliers.
787
- *
788
- * @remarks
789
- * Each tier applies only to the hours between its threshold and the next.
790
- * Tiers must be sorted by threshold ascending.
791
- *
792
- * @example
793
- * ```typescript
794
- * // Hours 0-40: base rate
795
- * // Hours 40-48: 1.5x
796
- * // Hours 48+: 2.0x
797
- * tieredOvertimeMultiplier([
798
- * { after: 40, factor: 1.5 },
799
- * { after: 48, factor: 2.0 },
800
- * ])
801
- * ```
802
- */
803
- export function tieredOvertimeMultiplier(
804
- tiers: [OvertimeTier, ...OvertimeTier[]],
805
- opts?: CostRuleOptions,
806
- ): RuleEntry {
807
- return makeRule("overtime-tiered-multiplier", { tiers, ...opts });
808
- }
809
-
810
- /**
811
- * Blocks or penalizes assignments during specified time periods.
812
- *
813
- * @remarks
814
- * At least one time scoping field is required (`dayOfWeek`, `dateRange`,
815
- * `dates`, or `recurringPeriods`).
816
- *
817
- * Use `from` for "off from this time until end of day" and `until` for
818
- * "off from start of day until this time."
819
- *
820
- * @example
821
- * ```typescript
822
- * timeOff({ appliesTo: "mauro", dayOfWeek: weekend }),
823
- * timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) }),
824
- * timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } }),
825
- * ```
826
- */
827
- export function timeOff(opts: TimeOffOptions): RuleEntry {
828
- return makeRule("time-off", { ...opts });
829
- }
830
-
831
- /**
832
- * Encourages or enforces that team members work the same shifts on a day.
833
- *
834
- * @example
835
- * ```typescript
836
- * assignTogether(["alice", "bob"], { priority: "HIGH" }),
837
- * ```
838
- */
839
- export function assignTogether(
840
- members: [string, string, ...string[]],
841
- opts?: AssignTogetherOptions,
842
- ): RuleEntry {
843
- return makeRule("assign-together", { members, ...opts });
844
- }
845
-
846
- // ============================================================================
847
- // Schedule Definition
848
- // ============================================================================
849
-
850
- /**
851
- * Runtime arguments passed to {@link ScheduleDefinition.createSchedulerConfig}.
852
- *
853
- * @remarks
854
- * Separates data known at runtime (team roster, date range, ad-hoc rules)
855
- * from the static schedule definition. Runtime rules are merged after the
856
- * definition's own rules and undergo the same `appliesTo` resolution.
857
- */
858
- export interface RuntimeArgs {
859
- /** The scheduling period (date range + optional filters). */
860
- schedulingPeriod: SchedulingPeriod;
861
- /** Team members available for this scheduling run. */
862
- members: SchedulingMember[];
863
- /** Ad-hoc rules injected at runtime (e.g., vacation, holiday closures). */
864
- runtimeRules?: RuleEntry[];
865
- }
866
-
867
- /** Result of {@link defineSchedule}. */
868
- export interface ScheduleDefinition {
869
- /** Produce a {@link ModelBuilderConfig} for the solver. */
870
- createSchedulerConfig(args: RuntimeArgs): ModelBuilderConfig;
871
- /** Declared role names. */
872
- readonly roles: readonly string[];
873
- /** Declared skill names. */
874
- readonly skills: readonly string[];
875
- /** Names of declared semantic times. */
876
- readonly timeNames: readonly string[];
877
- /** Shift pattern IDs. */
878
- readonly shiftPatternIds: readonly string[];
879
- /** Internal rule identifiers in kebab-case (e.g., "max-hours-day", "time-off"). */
880
- readonly ruleNames: readonly string[];
881
- }
882
-
883
- /**
884
- * Configuration for {@link defineSchedule}.
885
- *
886
- * @remarks
887
- * Coverage entries for the same semantic time and target stack additively.
888
- * An unscoped entry applies every day; adding a weekend-only entry on top
889
- * doubles the count on those days. Use mutually exclusive `dayOfWeek` on
890
- * both entries to avoid stacking. See {@link cover} for details.
891
- */
892
- export interface ScheduleConfig<
893
- R extends readonly string[],
894
- S extends readonly string[],
895
- T extends Record<string, SemanticTimeEntry>,
896
- > {
897
- /** Declared role names. */
898
- roles: R;
899
- /** Declared skill names. */
900
- skills?: S;
901
- /** Named semantic time periods. */
902
- times: T;
903
- /** Staffing requirements per time period (entries stack additively). */
904
- coverage: CoverageEntry<keyof T & string, R[number] | NonNullable<S>[number]>[];
905
- /** Available shift patterns. */
906
- shiftPatterns: ShiftPattern[];
907
- /** Scheduling rules and constraints. */
908
- rules?: RuleEntry[];
909
- /** Days of the week the business operates (inclusion filter). */
910
- dayOfWeek?: readonly DayOfWeek[];
911
- /** Which day starts the week for weekly rules. Defaults to `"monday"`. */
912
- weekStartsOn?: DayOfWeek;
913
- }
914
-
915
- /**
916
- * Defines a complete schedule configuration.
917
- *
918
- * @remarks
919
- * Validates the static config at call time (role/skill disjointness, coverage
920
- * targets, shift pattern roles). Returns a {@link ScheduleDefinition} whose
921
- * `createSchedulerConfig` method validates runtime data (member IDs,
922
- * `appliesTo` resolution) and produces a {@link ModelBuilderConfig}.
923
- *
924
- * @example
925
- * ```typescript
926
- * import { defineSchedule, t, time, cover, shift, maxHoursPerDay } from "dabke";
927
- *
928
- * export default defineSchedule({
929
- * roles: ["agent", "supervisor"],
930
- * times: { peak: time({ startTime: t(9), endTime: t(17) }) },
931
- * coverage: [cover("peak", "agent", 4)],
932
- * shiftPatterns: [shift("day", t(9), t(17))],
933
- * rules: [maxHoursPerDay(8)],
934
- * });
935
- * ```
936
- */
937
- export function defineSchedule<
938
- const R extends readonly string[],
939
- const S extends readonly string[],
940
- const T extends Record<string, SemanticTimeEntry>,
941
- >(config: ScheduleConfig<R, S, T>): ScheduleDefinition {
942
- const roles = new Set<string>(config.roles);
943
- const skills = new Set<string>(config.skills ?? []);
944
-
945
- // Validate role/skill disjointness
946
- for (const skill of skills) {
947
- if (roles.has(skill)) {
948
- throw new Error(
949
- `"${skill}" is declared as both a role and a skill. Roles and skills must be disjoint.`,
950
- );
951
- }
952
- }
953
-
954
- // Validate shift pattern role references
955
- for (const sp of config.shiftPatterns) {
956
- if (sp.roles) {
957
- for (const role of sp.roles) {
958
- if (!roles.has(role)) {
959
- throw new Error(
960
- `Shift pattern "${sp.id}" references unknown role "${role}". ` +
961
- `Declared roles: ${[...roles].join(", ")}`,
962
- );
963
- }
964
- }
965
- }
966
- }
967
-
968
- // Validate coverage entries
969
- for (const entry of config.coverage) {
970
- const targets = Array.isArray(entry.target) ? entry.target : [entry.target];
971
- if (Array.isArray(entry.target)) {
972
- // Array target: all must be roles (OR logic)
973
- for (const target of targets) {
974
- if (!roles.has(target)) {
975
- throw new Error(
976
- `Coverage for "${entry.timeName}" references "${target}" in a role OR group, ` +
977
- `but it is not a declared role. Declared roles: ${[...roles].join(", ")}`,
978
- );
979
- }
980
- }
981
- } else {
982
- // Single target: must be role or skill
983
- if (!roles.has(entry.target) && !skills.has(entry.target)) {
984
- throw new Error(
985
- `Coverage for "${entry.timeName}" references unknown target "${entry.target}". ` +
986
- `Declared roles: ${[...roles].join(", ")}. ` +
987
- `Declared skills: ${[...skills].join(", ")}`,
988
- );
989
- }
990
- }
991
- // Validate skills option
992
- if (entry.options.skills) {
993
- for (const s of entry.options.skills) {
994
- if (!skills.has(s)) {
995
- throw new Error(
996
- `Coverage for "${entry.timeName}" uses skill filter "${s}" ` +
997
- `which is not a declared skill. Declared skills: ${[...skills].join(", ")}`,
998
- );
999
- }
1000
- }
1001
- }
1002
- }
1003
-
1004
- // Build semantic time context
1005
- const semanticTimes = defineSemanticTimes(config.times);
1006
-
1007
- // Convert coverage entries to semantic coverage requirements
1008
- const coverageReqs = buildCoverageRequirements(config.coverage, roles, skills);
1009
-
1010
- const shiftPatterns = config.shiftPatterns;
1011
-
1012
- return {
1013
- roles: config.roles as readonly string[],
1014
- skills: (config.skills ?? []) as readonly string[],
1015
- timeNames: Object.keys(config.times) as readonly string[],
1016
- shiftPatternIds: config.shiftPatterns.map((sp) => sp.id) as readonly string[],
1017
- ruleNames: (config.rules ?? []).map((r: RuleEntry) => r._rule) as readonly string[],
1018
- createSchedulerConfig(args: RuntimeArgs): ModelBuilderConfig {
1019
- // Detect duplicate member IDs
1020
- const memberIds = new Set<string>();
1021
- for (const member of args.members) {
1022
- if (memberIds.has(member.id)) {
1023
- throw new Error(`Duplicate member ID "${member.id}".`);
1024
- }
1025
- memberIds.add(member.id);
1026
- }
1027
-
1028
- // Validate member IDs don't collide with roles/skills
1029
- for (const member of args.members) {
1030
- if (roles.has(member.id)) {
1031
- throw new Error(`Member ID "${member.id}" collides with a declared role name.`);
1032
- }
1033
- if (skills.has(member.id)) {
1034
- throw new Error(`Member ID "${member.id}" collides with a declared skill name.`);
1035
- }
1036
- }
1037
-
1038
- // Validate member roles/skills reference declared roles/skills
1039
- for (const member of args.members) {
1040
- for (const role of member.roles) {
1041
- if (!roles.has(role)) {
1042
- throw new Error(
1043
- `Member "${member.id}" references unknown role "${role}". ` +
1044
- `Declared roles: ${[...roles].join(", ")}`,
1045
- );
1046
- }
1047
- }
1048
- if (member.skills) {
1049
- for (const skill of member.skills) {
1050
- if (!skills.has(skill)) {
1051
- throw new Error(
1052
- `Member "${member.id}" references unknown skill "${skill}". ` +
1053
- `Declared skills: ${[...skills].join(", ")}`,
1054
- );
1055
- }
1056
- }
1057
- }
1058
- }
1059
-
1060
- // Resolve rules
1061
- const specRules = config.rules ?? [];
1062
- const runtimeRules = args.runtimeRules ?? [];
1063
- const allRules = [...specRules, ...runtimeRules];
1064
-
1065
- // Validate pay data when cost rules are present
1066
- const costRuleNames = new Set([
1067
- "minimize-cost",
1068
- "day-cost-multiplier",
1069
- "day-cost-surcharge",
1070
- "time-cost-surcharge",
1071
- "overtime-weekly-multiplier",
1072
- "overtime-weekly-surcharge",
1073
- "overtime-daily-multiplier",
1074
- "overtime-daily-surcharge",
1075
- "overtime-tiered-multiplier",
1076
- ]);
1077
- const hasCostRules = allRules.some((r) => costRuleNames.has(r._rule));
1078
- if (hasCostRules) {
1079
- const missingPay = args.members.filter((m) => !m.pay).map((m) => m.id);
1080
- if (missingPay.length > 0) {
1081
- throw new Error(
1082
- `Cost rules require pay data on all members. Missing pay: ${missingPay.join(", ")}`,
1083
- );
1084
- }
1085
- }
1086
-
1087
- // Sort rules so minimize-cost compiles before modifier rules
1088
- const sortedRules = sortCostRulesFirst(allRules);
1089
- const ruleConfigs = resolveRules(sortedRules, roles, skills, memberIds);
1090
-
1091
- // Resolve scheduling period with dayOfWeek filter
1092
- const resolvedPeriod = applyDaysFilter(args.schedulingPeriod, config.dayOfWeek);
1093
- const days = resolveDaysFromPeriod(resolvedPeriod);
1094
-
1095
- // Resolve coverage
1096
- const resolvedCoverage = semanticTimes.resolve(coverageReqs, days);
1097
-
1098
- return {
1099
- members: args.members,
1100
- shiftPatterns,
1101
- schedulingPeriod: resolvedPeriod,
1102
- coverage: resolvedCoverage,
1103
- ruleConfigs,
1104
- weekStartsOn: config.weekStartsOn,
1105
- };
1106
- },
1107
- };
1108
- }
1109
-
1110
- // ============================================================================
1111
- // Internal: Coverage Translation
1112
- // ============================================================================
1113
-
1114
- function buildCoverageRequirements<T extends string>(
1115
- entries: CoverageEntry<T, string>[],
1116
- roles: Set<string>,
1117
- skills: Set<string>,
1118
- ): MixedCoverageRequirement<T>[] {
1119
- return entries.map((entry) => {
1120
- // Variant form: produce a VariantCoverageRequirement
1121
- if (entry.variants) {
1122
- return buildVariantCoverageRequirement(entry, roles, skills);
1123
- }
1124
-
1125
- // Simple form: produce a SemanticCoverageRequirement
1126
- const base: {
1127
- semanticTime: T;
1128
- targetCount: number;
1129
- priority?: Priority;
1130
- dayOfWeek?: DayOfWeek[];
1131
- dates?: string[];
1132
- } = {
1133
- semanticTime: entry.timeName,
1134
- targetCount: entry.count,
1135
- };
1136
-
1137
- if (entry.options.priority) base.priority = entry.options.priority;
1138
- if (entry.options.dayOfWeek) base.dayOfWeek = entry.options.dayOfWeek as DayOfWeek[];
1139
- if (entry.options.dates) base.dates = entry.options.dates;
1140
-
1141
- return buildSimpleCoverageTarget(entry, base, roles, skills);
1142
- }) as MixedCoverageRequirement<T>[];
1143
- }
1144
-
1145
- /**
1146
- * Resolve the target (role/skill) for a simple coverage entry.
1147
- */
1148
- function buildSimpleCoverageTarget<T extends string>(
1149
- entry: CoverageEntry<T, string>,
1150
- base: {
1151
- semanticTime: T;
1152
- targetCount: number;
1153
- priority?: Priority;
1154
- dayOfWeek?: DayOfWeek[];
1155
- dates?: string[];
1156
- },
1157
- roles: Set<string>,
1158
- skills: Set<string>,
1159
- ): MixedCoverageRequirement<T> {
1160
- if (Array.isArray(entry.target)) {
1161
- return {
1162
- ...base,
1163
- roles: entry.target as [string, ...string[]],
1164
- } satisfies MixedCoverageRequirement<T>;
1165
- }
1166
-
1167
- const singleTarget = entry.target as string;
1168
- if (roles.has(singleTarget)) {
1169
- if (entry.options.skills) {
1170
- return {
1171
- ...base,
1172
- roles: [singleTarget] as [string, ...string[]],
1173
- skills: entry.options.skills,
1174
- } satisfies MixedCoverageRequirement<T>;
1175
- }
1176
- return {
1177
- ...base,
1178
- roles: [singleTarget] as [string, ...string[]],
1179
- } satisfies MixedCoverageRequirement<T>;
1180
- }
1181
-
1182
- if (skills.has(singleTarget)) {
1183
- return {
1184
- ...base,
1185
- skills: [singleTarget] as [string, ...string[]],
1186
- } satisfies MixedCoverageRequirement<T>;
1187
- }
1188
-
1189
- throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
1190
- }
1191
-
1192
- /**
1193
- * Build a VariantCoverageRequirement from a variant-form CoverageEntry.
1194
- */
1195
- function buildVariantCoverageRequirement<T extends string>(
1196
- entry: CoverageEntry<T, string>,
1197
- roles: Set<string>,
1198
- skills: Set<string>,
1199
- ): MixedCoverageRequirement<T> {
1200
- const variants = entry.variants! as unknown as [CoverageVariant, ...CoverageVariant[]];
1201
-
1202
- const resolveTarget = (): {
1203
- roles?: [string, ...string[]];
1204
- skills?: [string, ...string[]];
1205
- } => {
1206
- if (Array.isArray(entry.target)) {
1207
- return { roles: entry.target as [string, ...string[]] };
1208
- }
1209
- const singleTarget = entry.target as string;
1210
- if (roles.has(singleTarget)) {
1211
- return { roles: [singleTarget] as [string, ...string[]] };
1212
- }
1213
- if (skills.has(singleTarget)) {
1214
- return { skills: [singleTarget] as [string, ...string[]] };
1215
- }
1216
- throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
1217
- };
1218
-
1219
- return {
1220
- semanticTime: entry.timeName,
1221
- variants,
1222
- ...resolveTarget(),
1223
- } as MixedCoverageRequirement<T>;
1224
- }
1225
-
1226
- // ============================================================================
1227
- // Internal: Rule Translation
1228
- // ============================================================================
1229
-
1230
- /**
1231
- * Resolves an `appliesTo` value into entity scope fields.
1232
- *
1233
- * @remarks
1234
- * Each target string is checked against roles, skills, then member IDs.
1235
- * If all targets resolve to the same namespace, they are combined into one
1236
- * scope field. If they span namespaces, an error is thrown; the caller
1237
- * should use separate rule entries instead.
1238
- */
1239
- function resolveAppliesTo(
1240
- appliesTo: string | string[] | undefined,
1241
- roles: Set<string>,
1242
- skills: Set<string>,
1243
- memberIds: Set<string>,
1244
- ): {
1245
- memberIds?: [string, ...string[]];
1246
- roleIds?: [string, ...string[]];
1247
- skillIds?: [string, ...string[]];
1248
- } {
1249
- if (!appliesTo) return {};
1250
-
1251
- const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
1252
- if (targets.length === 0) return {};
1253
-
1254
- const resolvedRoles: string[] = [];
1255
- const resolvedSkills: string[] = [];
1256
- const resolvedMembers: string[] = [];
1257
-
1258
- for (const target of targets) {
1259
- if (roles.has(target)) {
1260
- resolvedRoles.push(target);
1261
- } else if (skills.has(target)) {
1262
- resolvedSkills.push(target);
1263
- } else if (memberIds.has(target)) {
1264
- resolvedMembers.push(target);
1265
- } else {
1266
- throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
1267
- }
1268
- }
1269
-
1270
- // Count how many namespaces were used
1271
- const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
1272
- (arr) => arr.length > 0,
1273
- ).length;
1274
-
1275
- if (namespacesUsed > 1) {
1276
- // Mixed namespaces not supported in a single rule config.
1277
- throw new Error(
1278
- `appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
1279
- `skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
1280
- `Use separate rule entries for each namespace.`,
1281
- );
1282
- }
1283
-
1284
- if (resolvedRoles.length > 0) {
1285
- return { roleIds: resolvedRoles as [string, ...string[]] };
1286
- }
1287
- if (resolvedSkills.length > 0) {
1288
- return { skillIds: resolvedSkills as [string, ...string[]] };
1289
- }
1290
- if (resolvedMembers.length > 0) {
1291
- return { memberIds: resolvedMembers as [string, ...string[]] };
1292
- }
1293
- return {};
1294
- }
1295
-
1296
- function resolveRules(
1297
- rules: RuleEntry[],
1298
- roles: Set<string>,
1299
- skills: Set<string>,
1300
- memberIds: Set<string>,
1301
- ): CpsatRuleConfigEntry[] {
1302
- return rules.map((rule) => {
1303
- const { _type, _rule, appliesTo, dates, ...passthrough } = rule as RuleEntry & {
1304
- appliesTo?: string | string[];
1305
- dates?: string[];
1306
- };
1307
-
1308
- const entityScope =
1309
- _rule === "assign-together" ? {} : resolveAppliesTo(appliesTo, roles, skills, memberIds);
1310
-
1311
- // Rename dates → specificDates (internal field name)
1312
- const resolvedDates = dates ? { specificDates: dates } : {};
1313
-
1314
- switch (_rule) {
1315
- case "time-off": {
1316
- const { from, until, ...timeOffRest } = passthrough as Record<string, unknown> & {
1317
- from?: TimeOfDay;
1318
- until?: TimeOfDay;
1319
- };
1320
-
1321
- if (
1322
- !timeOffRest.dayOfWeek &&
1323
- !timeOffRest.dateRange &&
1324
- !dates &&
1325
- !timeOffRest.recurringPeriods
1326
- ) {
1327
- throw new Error(
1328
- "timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
1329
- );
1330
- }
1331
-
1332
- const partialDay: Record<string, unknown> = {};
1333
- if (from && until) {
1334
- partialDay.startTime = from;
1335
- partialDay.endTime = until;
1336
- } else if (from) {
1337
- partialDay.startTime = from;
1338
- partialDay.endTime = { hours: 23, minutes: 59 };
1339
- } else if (until) {
1340
- partialDay.startTime = { hours: 0, minutes: 0 };
1341
- partialDay.endTime = until;
1342
- }
1343
-
1344
- return {
1345
- name: _rule,
1346
- ...timeOffRest,
1347
- ...entityScope,
1348
- ...resolvedDates,
1349
- ...partialDay,
1350
- } as CpsatRuleConfigEntry;
1351
- }
1352
-
1353
- case "assign-together": {
1354
- const { members, ...atRest } = passthrough as Record<string, unknown> & {
1355
- members: [string, string, ...string[]];
1356
- };
1357
- for (const member of members) {
1358
- if (!memberIds.has(member)) {
1359
- throw new Error(
1360
- `assignTogether references unknown member "${member}". ` +
1361
- `Known member IDs: ${[...memberIds].join(", ")}`,
1362
- );
1363
- }
1364
- }
1365
- return { name: _rule, groupMemberIds: members, ...atRest } as CpsatRuleConfigEntry;
1366
- }
1367
-
1368
- default:
1369
- return {
1370
- name: _rule,
1371
- ...passthrough,
1372
- ...entityScope,
1373
- ...resolvedDates,
1374
- } as CpsatRuleConfigEntry;
1375
- }
1376
- }) as CpsatRuleConfigEntry[];
1377
- }
1378
-
1379
- // ============================================================================
1380
- // Internal: Cost Rule Ordering
1381
- // ============================================================================
1382
-
1383
- /**
1384
- * Sorts rules so that `minimize-cost` compiles before cost modifier rules.
1385
- *
1386
- * @remarks
1387
- * The `minimize-cost` rule must be compiled first because modifier rules
1388
- * (multipliers, surcharges) reference cost variables it creates.
1389
- * Non-cost rules retain their original relative order.
1390
- */
1391
- function sortCostRulesFirst(rules: RuleEntry[]): RuleEntry[] {
1392
- return rules.toSorted((a, b) => {
1393
- const aIsCostBase = a._rule === "minimize-cost" ? 0 : 1;
1394
- const bIsCostBase = b._rule === "minimize-cost" ? 0 : 1;
1395
- return aIsCostBase - bIsCostBase;
1396
- });
1397
- }
1398
-
1399
- // ============================================================================
1400
- // Internal: Scheduling Period Helpers
1401
- // ============================================================================
1402
-
1403
- function applyDaysFilter(
1404
- schedulingPeriod: SchedulingPeriod,
1405
- dayOfWeek?: readonly DayOfWeek[],
1406
- ): SchedulingPeriod {
1407
- if (!dayOfWeek || dayOfWeek.length === 0) {
1408
- return schedulingPeriod;
1409
- }
1410
-
1411
- const existingDays = schedulingPeriod.dayOfWeek;
1412
- if (!existingDays || existingDays.length === 0) {
1413
- return { ...schedulingPeriod, dayOfWeek: dayOfWeek as DayOfWeek[] };
1414
- }
1415
-
1416
- const existingSet = new Set(existingDays);
1417
- const intersected = dayOfWeek.filter((day) => existingSet.has(day)) as DayOfWeek[];
1418
- return { ...schedulingPeriod, dayOfWeek: intersected };
1419
- }