dabke 0.82.0 → 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 (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -3
  3. package/dist/cpsat/rules/index.d.ts +3 -0
  4. package/dist/cpsat/rules/index.d.ts.map +1 -1
  5. package/dist/cpsat/rules/index.js +3 -0
  6. package/dist/cpsat/rules/index.js.map +1 -1
  7. package/dist/cpsat/rules/max-days-week.d.ts +44 -0
  8. package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
  9. package/dist/cpsat/rules/max-days-week.js +95 -0
  10. package/dist/cpsat/rules/max-days-week.js.map +1 -0
  11. package/dist/cpsat/rules/min-days-week.d.ts +34 -0
  12. package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
  13. package/dist/cpsat/rules/min-days-week.js +84 -0
  14. package/dist/cpsat/rules/min-days-week.js.map +1 -0
  15. package/dist/cpsat/rules/must-assign.d.ts +49 -0
  16. package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
  17. package/dist/cpsat/rules/must-assign.js +86 -0
  18. package/dist/cpsat/rules/must-assign.js.map +1 -0
  19. package/dist/cpsat/rules/registry.d.ts +4 -1
  20. package/dist/cpsat/rules/registry.d.ts.map +1 -1
  21. package/dist/cpsat/rules/registry.js +4 -1
  22. package/dist/cpsat/rules/registry.js.map +1 -1
  23. package/dist/cpsat/rules/rules.types.d.ts +3 -0
  24. package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
  25. package/dist/cpsat/rules/scope.types.d.ts +1 -1
  26. package/dist/index.d.ts +5 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/schedule/cost.d.ts +204 -0
  31. package/dist/schedule/cost.d.ts.map +1 -0
  32. package/dist/schedule/cost.js +187 -0
  33. package/dist/schedule/cost.js.map +1 -0
  34. package/dist/schedule/coverage.d.ts +85 -0
  35. package/dist/schedule/coverage.d.ts.map +1 -0
  36. package/dist/schedule/coverage.js +33 -0
  37. package/dist/schedule/coverage.js.map +1 -0
  38. package/dist/schedule/definition.d.ts +227 -0
  39. package/dist/schedule/definition.d.ts.map +1 -0
  40. package/dist/{schedule.js → schedule/definition.js} +9 -673
  41. package/dist/schedule/definition.js.map +1 -0
  42. package/dist/schedule/index.d.ts +67 -0
  43. package/dist/schedule/index.d.ts.map +1 -0
  44. package/dist/schedule/index.js +69 -0
  45. package/dist/schedule/index.js.map +1 -0
  46. package/dist/schedule/rules.d.ts +353 -0
  47. package/dist/schedule/rules.d.ts.map +1 -0
  48. package/dist/schedule/rules.js +352 -0
  49. package/dist/schedule/rules.js.map +1 -0
  50. package/dist/schedule/shift-patterns.d.ts +34 -0
  51. package/dist/schedule/shift-patterns.d.ts.map +1 -0
  52. package/dist/schedule/shift-patterns.js +41 -0
  53. package/dist/schedule/shift-patterns.js.map +1 -0
  54. package/dist/schedule/time-periods.d.ts +69 -0
  55. package/dist/schedule/time-periods.d.ts.map +1 -0
  56. package/dist/schedule/time-periods.js +91 -0
  57. package/dist/schedule/time-periods.js.map +1 -0
  58. package/package.json +4 -9
  59. package/src/cpsat/rules/index.ts +3 -0
  60. package/src/cpsat/rules/max-days-week.ts +143 -0
  61. package/src/cpsat/rules/min-days-week.ts +120 -0
  62. package/src/cpsat/rules/must-assign.ts +108 -0
  63. package/src/cpsat/rules/registry.ts +6 -0
  64. package/src/cpsat/rules/rules.types.ts +3 -0
  65. package/src/cpsat/rules/scope.types.ts +1 -1
  66. package/src/index.ts +8 -3
  67. package/src/schedule/cost.ts +242 -0
  68. package/src/schedule/coverage.ts +135 -0
  69. package/src/schedule/definition.ts +958 -0
  70. package/src/schedule/index.ts +112 -0
  71. package/src/schedule/rules.ts +529 -0
  72. package/src/schedule/shift-patterns.ts +46 -0
  73. package/src/schedule/time-periods.ts +110 -0
  74. package/dist/llms.d.ts +0 -2
  75. package/dist/llms.d.ts.map +0 -1
  76. package/dist/llms.js +0 -3
  77. package/dist/llms.js.map +0 -1
  78. package/dist/schedule.d.ts +0 -917
  79. package/dist/schedule.d.ts.map +0 -1
  80. package/dist/schedule.js.map +0 -1
  81. package/llms.txt +0 -758
  82. package/src/llms.ts +0 -3
  83. package/src/schedule.ts +0 -1960
@@ -0,0 +1,112 @@
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
+ * schedule, t, time, cover, shift,
12
+ * maxHoursPerDay, maxHoursPerWeek, minRestBetweenShifts,
13
+ * weekdays, weekend,
14
+ * } from "dabke";
15
+ *
16
+ * const venue = schedule({
17
+ * roleIds: ["cashier", "floor_lead", "stocker"],
18
+ * skillIds: ["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
+ * const result = await venue
49
+ * .with([
50
+ * { id: "alice", roleIds: ["cashier"], skillIds: ["keyholder"] },
51
+ * ])
52
+ * .solve(client, { dateRange: { start: "2025-03-03", end: "2025-03-09" } });
53
+ * ```
54
+ *
55
+ * @module
56
+ */
57
+
58
+ // Time Periods
59
+ export { t, weekdays, weekend, time } from "./time-periods.js";
60
+
61
+ // Coverage
62
+ export { cover } from "./coverage.js";
63
+ export type { CoverageOptions, CoverageEntry, CoverageVariant } from "./coverage.js";
64
+
65
+ // Shift Patterns
66
+ export { shift } from "./shift-patterns.js";
67
+
68
+ // Rules
69
+ export {
70
+ defineRule,
71
+ maxHoursPerDay,
72
+ maxHoursPerWeek,
73
+ minHoursPerDay,
74
+ minHoursPerWeek,
75
+ maxDaysPerWeek,
76
+ minDaysPerWeek,
77
+ maxShiftsPerDay,
78
+ maxConsecutiveDays,
79
+ minConsecutiveDays,
80
+ minRestBetweenShifts,
81
+ mustAssign,
82
+ preference,
83
+ preferLocation,
84
+ timeOff,
85
+ assignTogether,
86
+ } from "./rules.js";
87
+ export type {
88
+ RuleEntry,
89
+ RuleResolveContext,
90
+ RuleOptions,
91
+ EntityOnlyRuleOptions,
92
+ TimeOffOptions,
93
+ AssignTogetherOptions,
94
+ } from "./rules.js";
95
+
96
+ // Cost Optimization
97
+ export {
98
+ minimizeCost,
99
+ dayMultiplier,
100
+ daySurcharge,
101
+ timeSurcharge,
102
+ overtimeMultiplier,
103
+ overtimeSurcharge,
104
+ dailyOvertimeMultiplier,
105
+ dailyOvertimeSurcharge,
106
+ tieredOvertimeMultiplier,
107
+ } from "./cost.js";
108
+ export type { CostRuleOptions } from "./cost.js";
109
+
110
+ // Schedule Definition
111
+ export { schedule, partialSchedule, Schedule } from "./definition.js";
112
+ export type { ScheduleConfig, SolveResult, SolveStatus, SolveOptions } from "./definition.js";
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Scheduling rules: constraints, preferences, and time-off.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { DayOfWeek, TimeOfDay } from "../types.js";
8
+ import type { Priority } from "../cpsat/types.js";
9
+ import type { CpsatRuleName, CpsatRuleConfigEntry } from "../cpsat/rules/rules.types.js";
10
+ import type { RecurringPeriod } from "../cpsat/rules/scope.types.js";
11
+
12
+ // ============================================================================
13
+ // Rule Options
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Scoping options shared by most rule functions.
18
+ *
19
+ * @remarks
20
+ * Default priority is `MANDATORY`. Use `appliesTo` to scope to a
21
+ * role, skill, or member ID. Use time scoping options (`dayOfWeek`,
22
+ * `dateRange`, `dates`) to limit when the rule applies.
23
+ * Not all rules support all scoping options. Entity-only rules
24
+ * (e.g., {@link maxConsecutiveDays}) ignore time scoping.
25
+ *
26
+ * @category Rules
27
+ */
28
+ export interface RuleOptions {
29
+ /** Who this rule applies to (role name, skill name, or member ID). */
30
+ appliesTo?: string | string[];
31
+ /** Restrict to specific days of the week. */
32
+ dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
33
+ /** Restrict to a date range. */
34
+ dateRange?: { start: string; end: string };
35
+ /** Restrict to specific dates (YYYY-MM-DD). */
36
+ dates?: string[];
37
+ /** Restrict to recurring calendar periods. */
38
+ recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
39
+ /** Defaults to `"MANDATORY"`. */
40
+ priority?: Priority;
41
+ }
42
+
43
+ /**
44
+ * Options for rules that support entity scoping only (no time scoping).
45
+ *
46
+ * @remarks
47
+ * Used by rules whose semantics are inherently per-day or per-week
48
+ * (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
49
+ * be meaningfully restricted to a date range or day of week.
50
+ *
51
+ * @category Rules
52
+ */
53
+ export interface EntityOnlyRuleOptions {
54
+ /** Who this rule applies to (role name, skill name, or member ID). */
55
+ appliesTo?: string | string[];
56
+ /** Defaults to `"MANDATORY"`. */
57
+ priority?: Priority;
58
+ }
59
+
60
+ /**
61
+ * Options for {@link timeOff}.
62
+ *
63
+ * @remarks
64
+ * At least one time scoping field is required (`dayOfWeek`, `dateRange`,
65
+ * `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
66
+ * of a day.
67
+ *
68
+ * @category Rules
69
+ */
70
+ export interface TimeOffOptions {
71
+ /** Who this rule applies to (role name, skill name, or member ID). */
72
+ appliesTo?: string | string[];
73
+ /** Off from this time until end of day. */
74
+ from?: TimeOfDay;
75
+ /** Off from start of day until this time. */
76
+ until?: TimeOfDay;
77
+ /** Restrict to specific days of the week. */
78
+ dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
79
+ /** Restrict to a date range. */
80
+ dateRange?: { start: string; end: string };
81
+ /** Restrict to specific dates (YYYY-MM-DD). */
82
+ dates?: string[];
83
+ /** Restrict to recurring calendar periods. */
84
+ recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
85
+ /** Defaults to `"MANDATORY"`. */
86
+ priority?: Priority;
87
+ }
88
+
89
+ /**
90
+ * Options for {@link assignTogether}.
91
+ *
92
+ * @category Rules
93
+ */
94
+ export interface AssignTogetherOptions {
95
+ /** Defaults to `"MANDATORY"`. */
96
+ priority?: Priority;
97
+ }
98
+
99
+ // ============================================================================
100
+ // Rule Entry
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Context passed to a rule's resolve function during compilation.
105
+ *
106
+ * Contains the declared roles, skills, and member IDs so the resolver
107
+ * can translate user-facing fields (like `appliesTo`) into internal
108
+ * scoping fields.
109
+ */
110
+ export interface RuleResolveContext {
111
+ readonly roles: ReadonlySet<string>;
112
+ readonly skills: ReadonlySet<string>;
113
+ readonly memberIds: ReadonlySet<string>;
114
+ }
115
+
116
+ // Internal rule entry type
117
+ interface RuleEntryBase {
118
+ readonly _type: "rule";
119
+ readonly _rule: string;
120
+ /**
121
+ * Optional custom resolver. When present, `resolveRules()` calls this
122
+ * instead of the default translation path. Built-in rules that need
123
+ * special field mapping (e.g., `timeOff`, `assignTogether`) attach one;
124
+ * all other rules use the default resolver.
125
+ */
126
+ readonly _resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string };
127
+ }
128
+
129
+ /**
130
+ * An opaque rule entry returned by rule functions.
131
+ *
132
+ * @remarks
133
+ * Pass these directly into the `rules` array of {@link ScheduleConfig}.
134
+ * The internal fields are resolved during compilation.
135
+ */
136
+ export type RuleEntry = RuleEntryBase & Record<string, unknown>;
137
+
138
+ /**
139
+ * Creates a rule entry for use in {@link ScheduleConfig.rules}.
140
+ *
141
+ * Built-in rules use the helpers (`maxHoursPerDay`, `timeOff`, etc.).
142
+ * Custom rules can use `defineRule` to create entries that plug into the
143
+ * same resolution and compilation pipeline.
144
+ *
145
+ * @param name - Rule name. Must match a key in the rule factory registry.
146
+ * @param fields - Rule-specific configuration fields.
147
+ * @param resolve - Optional custom resolver. When omitted, the default
148
+ * resolution applies: `appliesTo` is mapped to `roleIds`/`skillIds`/`memberIds`,
149
+ * `dates` is renamed to `specificDates`, and all other fields pass through.
150
+ */
151
+ export function defineRule(
152
+ name: string,
153
+ fields: Record<string, unknown>,
154
+ resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string },
155
+ ): RuleEntry {
156
+ const { _type: _, _rule: __, ...safeFields } = fields;
157
+ const entry: RuleEntry = { _type: "rule", _rule: name, ...safeFields };
158
+ if (resolve) {
159
+ Object.defineProperty(entry, "_resolve", { value: resolve, enumerable: false });
160
+ }
161
+ return entry;
162
+ }
163
+
164
+ function makeRule(rule: CpsatRuleName, fields: Record<string, unknown>): RuleEntry {
165
+ return defineRule(rule, fields);
166
+ }
167
+
168
+ // ============================================================================
169
+ // Constraint Rules
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Limits hours per day.
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * maxHoursPerDay(10)
178
+ * maxHoursPerDay(4, { appliesTo: "student", dayOfWeek: weekdays })
179
+ * ```
180
+ *
181
+ * @category Rules
182
+ */
183
+ export function maxHoursPerDay(hours: number, opts?: RuleOptions): RuleEntry {
184
+ return makeRule("max-hours-day", { hours, ...opts });
185
+ }
186
+
187
+ /**
188
+ * Limits hours per scheduling week.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * maxHoursPerWeek(48)
193
+ * maxHoursPerWeek(20, { appliesTo: "student" })
194
+ * ```
195
+ *
196
+ * @category Rules
197
+ */
198
+ export function maxHoursPerWeek(hours: number, opts?: RuleOptions): RuleEntry {
199
+ return makeRule("max-hours-week", { hours, ...opts });
200
+ }
201
+
202
+ /**
203
+ * Minimum hours when assigned on a day.
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * minHoursPerDay(4)
208
+ * ```
209
+ *
210
+ * @category Rules
211
+ */
212
+ export function minHoursPerDay(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
213
+ return makeRule("min-hours-day", { hours, ...opts });
214
+ }
215
+
216
+ /**
217
+ * Minimum hours per scheduling week.
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * minHoursPerWeek(20, { priority: "HIGH" })
222
+ * ```
223
+ *
224
+ * @category Rules
225
+ */
226
+ export function minHoursPerWeek(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
227
+ return makeRule("min-hours-week", { hours, ...opts });
228
+ }
229
+
230
+ /**
231
+ * Limits working days per scheduling week.
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * maxDaysPerWeek(5)
236
+ * maxDaysPerWeek(3, { appliesTo: "part-time" })
237
+ * ```
238
+ *
239
+ * @category Rules
240
+ */
241
+ export function maxDaysPerWeek(days: number, opts?: RuleOptions): RuleEntry {
242
+ return makeRule("max-days-week", { days, ...opts });
243
+ }
244
+
245
+ /**
246
+ * Minimum working days per scheduling week.
247
+ *
248
+ * @example
249
+ * ```typescript
250
+ * minDaysPerWeek(3, { priority: "HIGH" })
251
+ * minDaysPerWeek(5, { appliesTo: "full-time" })
252
+ * ```
253
+ *
254
+ * @category Rules
255
+ */
256
+ export function minDaysPerWeek(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
257
+ return makeRule("min-days-week", { days, ...opts });
258
+ }
259
+
260
+ /**
261
+ * Maximum distinct shifts per day.
262
+ *
263
+ * @example
264
+ * ```typescript
265
+ * maxShiftsPerDay(1)
266
+ * maxShiftsPerDay(2, { appliesTo: "student", dayOfWeek: weekend })
267
+ * ```
268
+ *
269
+ * @category Rules
270
+ */
271
+ export function maxShiftsPerDay(shifts: number, opts?: RuleOptions): RuleEntry {
272
+ return makeRule("max-shifts-day", { shifts, ...opts });
273
+ }
274
+
275
+ /**
276
+ * Maximum consecutive working days.
277
+ *
278
+ * @example
279
+ * ```typescript
280
+ * maxConsecutiveDays(5)
281
+ * ```
282
+ *
283
+ * @category Rules
284
+ */
285
+ export function maxConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
286
+ return makeRule("max-consecutive-days", { days, ...opts });
287
+ }
288
+
289
+ /**
290
+ * Once working, continue for at least this many consecutive days.
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * minConsecutiveDays(2, { priority: "HIGH" })
295
+ * ```
296
+ *
297
+ * @category Rules
298
+ */
299
+ export function minConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
300
+ return makeRule("min-consecutive-days", { days, ...opts });
301
+ }
302
+
303
+ /**
304
+ * Minimum rest hours between shifts.
305
+ *
306
+ * @example
307
+ * ```typescript
308
+ * minRestBetweenShifts(10)
309
+ * ```
310
+ *
311
+ * @category Rules
312
+ */
313
+ export function minRestBetweenShifts(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
314
+ return makeRule("min-rest-between-shifts", { hours, ...opts });
315
+ }
316
+
317
+ /**
318
+ * Prefer (`"high"`) or avoid (`"low"`) assigning. Requires `appliesTo`.
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * preference("high", { appliesTo: "waiter" })
323
+ * preference("low", { appliesTo: "student", dayOfWeek: weekdays })
324
+ * ```
325
+ *
326
+ * @category Rules
327
+ */
328
+ export function preference(level: "high" | "low", opts?: Omit<RuleOptions, "priority">): RuleEntry {
329
+ return makeRule("assignment-priority", { preference: level, ...opts });
330
+ }
331
+
332
+ /**
333
+ * Prefer assigning to shifts at a specific location. Requires `appliesTo`.
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * preferLocation("terrace", { appliesTo: "alice" })
338
+ * ```
339
+ *
340
+ * @category Rules
341
+ */
342
+ export function preferLocation(locationId: string, opts?: EntityOnlyRuleOptions): RuleEntry {
343
+ return makeRule("location-preference", { locationId, ...opts });
344
+ }
345
+
346
+ /**
347
+ * Guarantees that targeted members appear on the schedule each week.
348
+ *
349
+ * @remarks
350
+ * Use for staffing obligations: salaried employees who are paid regardless
351
+ * of whether they work, or contracted staff who must be rostered. The solver
352
+ * ensures each targeted member has at least one assignment per scheduling week.
353
+ *
354
+ * Always a soft constraint (HIGH priority internally). The schedule still
355
+ * generates when a member genuinely cannot be placed (e.g., full week of
356
+ * absences). Violations surface as validation warnings with distinct
357
+ * messaging from {@link minDaysPerWeek}. Priority is not configurable;
358
+ * the rule name communicates the intent.
359
+ *
360
+ * @example
361
+ * ```typescript
362
+ * mustAssign({ appliesTo: "diana" })
363
+ * mustAssign({ appliesTo: ["diana", "yavuz"] })
364
+ * ```
365
+ *
366
+ * @category Rules
367
+ */
368
+ export function mustAssign(opts?: { appliesTo?: string | string[] }): RuleEntry {
369
+ return makeRule("must-assign", { ...opts });
370
+ }
371
+
372
+ // ============================================================================
373
+ // Special Rules (custom resolvers)
374
+ // ============================================================================
375
+
376
+ /**
377
+ * Block assignments during specified periods.
378
+ * Requires at least one time scope (`dayOfWeek`, `dateRange`, `dates`, or `from`/`until`).
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * // Full days off
383
+ * timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } })
384
+ *
385
+ * // Every weekend off
386
+ * timeOff({ appliesTo: "mauro", dayOfWeek: weekend })
387
+ *
388
+ * // Wednesday afternoons off
389
+ * timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) })
390
+ * ```
391
+ *
392
+ * @category Rules
393
+ */
394
+ export function timeOff(opts: TimeOffOptions): RuleEntry {
395
+ const { from, until, ...rest } = opts;
396
+ return defineRule("time-off", { from, until, ...rest }, (ctx) => {
397
+ if (!rest.dayOfWeek && !rest.dateRange && !rest.dates && !rest.recurringPeriods) {
398
+ throw new Error(
399
+ "timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
400
+ );
401
+ }
402
+
403
+ const { appliesTo, dates, ...passthrough } = rest;
404
+ const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
405
+ const resolvedDates = dates ? { specificDates: dates } : {};
406
+
407
+ const partialDay: Record<string, unknown> = {};
408
+ if (from && until) {
409
+ partialDay.startTime = from;
410
+ partialDay.endTime = until;
411
+ } else if (from) {
412
+ partialDay.startTime = from;
413
+ partialDay.endTime = { hours: 23, minutes: 59 };
414
+ } else if (until) {
415
+ partialDay.startTime = { hours: 0, minutes: 0 };
416
+ partialDay.endTime = until;
417
+ }
418
+
419
+ return {
420
+ name: "time-off",
421
+ ...passthrough,
422
+ ...entityScope,
423
+ ...resolvedDates,
424
+ ...partialDay,
425
+ } as CpsatRuleConfigEntry;
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Members work the same shifts on days they are both assigned.
431
+ *
432
+ * @example
433
+ * ```typescript
434
+ * assignTogether(["alice", "bob"])
435
+ * assignTogether(["alice", "bob", "charlie"], { priority: "HIGH" })
436
+ * ```
437
+ *
438
+ * @category Rules
439
+ */
440
+ export function assignTogether(
441
+ memberIds: [string, string, ...string[]],
442
+ opts?: AssignTogetherOptions,
443
+ ): RuleEntry {
444
+ return defineRule("assign-together", { members: memberIds, ...opts }, (ctx) => {
445
+ for (const member of memberIds) {
446
+ if (!ctx.memberIds.has(member)) {
447
+ throw new Error(
448
+ `assignTogether references unknown member "${member}". ` +
449
+ `Known member IDs: ${[...ctx.memberIds].join(", ")}`,
450
+ );
451
+ }
452
+ }
453
+ return {
454
+ name: "assign-together",
455
+ groupMemberIds: memberIds,
456
+ ...opts,
457
+ } as CpsatRuleConfigEntry;
458
+ });
459
+ }
460
+
461
+ // ============================================================================
462
+ // Internal: appliesTo resolution (shared with definition.ts)
463
+ // ============================================================================
464
+
465
+ /**
466
+ * Resolves an `appliesTo` value into entity scope fields.
467
+ *
468
+ * Each target string is checked against roles, skills, then member IDs.
469
+ * If all targets resolve to the same namespace, they are combined into one
470
+ * scope field. If they span namespaces, an error is thrown; the caller
471
+ * should use separate rule entries instead.
472
+ *
473
+ * @internal
474
+ */
475
+ export function resolveAppliesTo(
476
+ appliesTo: string | string[] | undefined,
477
+ roles: ReadonlySet<string>,
478
+ skills: ReadonlySet<string>,
479
+ memberIds: ReadonlySet<string>,
480
+ ): {
481
+ memberIds?: [string, ...string[]];
482
+ roleIds?: [string, ...string[]];
483
+ skillIds?: [string, ...string[]];
484
+ } {
485
+ if (!appliesTo) return {};
486
+
487
+ const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
488
+ if (targets.length === 0) return {};
489
+
490
+ const resolvedRoles: string[] = [];
491
+ const resolvedSkills: string[] = [];
492
+ const resolvedMembers: string[] = [];
493
+
494
+ for (const target of targets) {
495
+ if (roles.has(target)) {
496
+ resolvedRoles.push(target);
497
+ } else if (skills.has(target)) {
498
+ resolvedSkills.push(target);
499
+ } else if (memberIds.has(target)) {
500
+ resolvedMembers.push(target);
501
+ } else {
502
+ throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
503
+ }
504
+ }
505
+
506
+ // Count how many namespaces were used
507
+ const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
508
+ (arr) => arr.length > 0,
509
+ ).length;
510
+
511
+ if (namespacesUsed > 1) {
512
+ throw new Error(
513
+ `appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
514
+ `skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
515
+ `Use separate rule entries for each namespace.`,
516
+ );
517
+ }
518
+
519
+ if (resolvedRoles.length > 0) {
520
+ return { roleIds: resolvedRoles as [string, ...string[]] };
521
+ }
522
+ if (resolvedSkills.length > 0) {
523
+ return { skillIds: resolvedSkills as [string, ...string[]] };
524
+ }
525
+ if (resolvedMembers.length > 0) {
526
+ return { memberIds: resolvedMembers as [string, ...string[]] };
527
+ }
528
+ return {};
529
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shift pattern definitions: time slots available for employee assignment.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { TimeOfDay } from "../types.js";
8
+ import type { ShiftPattern } from "../cpsat/types.js";
9
+
10
+ /**
11
+ * Define a shift pattern: a time slot available for employee assignment.
12
+ *
13
+ * @remarks
14
+ * Each pattern repeats daily unless filtered by `dayOfWeek`.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * shiftPatterns: [
19
+ * shift("morning", t(11, 30), t(15)),
20
+ * shift("evening", t(17), t(22)),
21
+ *
22
+ * // Role-restricted shift
23
+ * shift("kitchen", t(6), t(14), { roleIds: ["chef", "prep_cook"] }),
24
+ *
25
+ * // Day-restricted shift
26
+ * shift("saturday_short", t(9), t(14), { dayOfWeek: ["saturday"] }),
27
+ *
28
+ * // Location-specific shift
29
+ * shift("terrace_lunch", t(12), t(16), { locationId: "terrace" }),
30
+ * ]
31
+ * ```
32
+ *
33
+ * @category Shift Patterns
34
+ */
35
+ export function shift(
36
+ id: string,
37
+ startTime: TimeOfDay,
38
+ endTime: TimeOfDay,
39
+ opts?: Pick<ShiftPattern, "roleIds" | "dayOfWeek" | "locationId">,
40
+ ): ShiftPattern {
41
+ const pattern: ShiftPattern = { id, startTime, endTime };
42
+ if (opts?.roleIds) pattern.roleIds = opts.roleIds;
43
+ if (opts?.dayOfWeek) pattern.dayOfWeek = opts.dayOfWeek;
44
+ if (opts?.locationId) pattern.locationId = opts.locationId;
45
+ return pattern;
46
+ }