dabke 0.78.2 → 0.80.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 (129) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +68 -31
  3. package/dist/client.types.d.ts +58 -0
  4. package/dist/client.types.d.ts.map +1 -1
  5. package/dist/client.types.js.map +1 -1
  6. package/dist/cpsat/model-builder.d.ts +13 -2
  7. package/dist/cpsat/model-builder.d.ts.map +1 -1
  8. package/dist/cpsat/model-builder.js.map +1 -1
  9. package/dist/cpsat/response.d.ts +12 -3
  10. package/dist/cpsat/response.d.ts.map +1 -1
  11. package/dist/cpsat/response.js.map +1 -1
  12. package/dist/cpsat/rules/assign-together.d.ts +7 -0
  13. package/dist/cpsat/rules/assign-together.d.ts.map +1 -1
  14. package/dist/cpsat/rules/assign-together.js +1 -0
  15. package/dist/cpsat/rules/assign-together.js.map +1 -1
  16. package/dist/cpsat/rules/employee-assignment-priority.d.ts +11 -37
  17. package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -1
  18. package/dist/cpsat/rules/employee-assignment-priority.js +12 -104
  19. package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -1
  20. package/dist/cpsat/rules/location-preference.d.ts +12 -10
  21. package/dist/cpsat/rules/location-preference.d.ts.map +1 -1
  22. package/dist/cpsat/rules/location-preference.js +16 -14
  23. package/dist/cpsat/rules/location-preference.js.map +1 -1
  24. package/dist/cpsat/rules/max-consecutive-days.d.ts +12 -13
  25. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -1
  26. package/dist/cpsat/rules/max-consecutive-days.js +11 -12
  27. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -1
  28. package/dist/cpsat/rules/max-hours-day.d.ts +12 -28
  29. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -1
  30. package/dist/cpsat/rules/max-hours-day.js +12 -95
  31. package/dist/cpsat/rules/max-hours-day.js.map +1 -1
  32. package/dist/cpsat/rules/max-hours-week.d.ts +14 -34
  33. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
  34. package/dist/cpsat/rules/max-hours-week.js +12 -103
  35. package/dist/cpsat/rules/max-hours-week.js.map +1 -1
  36. package/dist/cpsat/rules/max-shifts-day.d.ts +14 -39
  37. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
  38. package/dist/cpsat/rules/max-shifts-day.js +14 -106
  39. package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
  40. package/dist/cpsat/rules/min-consecutive-days.d.ts +12 -13
  41. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
  42. package/dist/cpsat/rules/min-consecutive-days.js +11 -12
  43. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
  44. package/dist/cpsat/rules/min-hours-day.d.ts +12 -13
  45. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -1
  46. package/dist/cpsat/rules/min-hours-day.js +11 -12
  47. package/dist/cpsat/rules/min-hours-day.js.map +1 -1
  48. package/dist/cpsat/rules/min-hours-week.d.ts +13 -13
  49. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
  50. package/dist/cpsat/rules/min-hours-week.js +10 -14
  51. package/dist/cpsat/rules/min-hours-week.js.map +1 -1
  52. package/dist/cpsat/rules/min-rest-between-shifts.d.ts +13 -14
  53. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
  54. package/dist/cpsat/rules/min-rest-between-shifts.js +11 -12
  55. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
  56. package/dist/cpsat/rules/resolver.d.ts +2 -2
  57. package/dist/cpsat/rules/resolver.d.ts.map +1 -1
  58. package/dist/cpsat/rules/resolver.js +55 -30
  59. package/dist/cpsat/rules/resolver.js.map +1 -1
  60. package/dist/cpsat/rules/scope.types.d.ts +267 -0
  61. package/dist/cpsat/rules/scope.types.d.ts.map +1 -0
  62. package/dist/cpsat/rules/scope.types.js +325 -0
  63. package/dist/cpsat/rules/scope.types.js.map +1 -0
  64. package/dist/cpsat/rules/time-off.d.ts +21 -25
  65. package/dist/cpsat/rules/time-off.d.ts.map +1 -1
  66. package/dist/cpsat/rules/time-off.js +20 -110
  67. package/dist/cpsat/rules/time-off.js.map +1 -1
  68. package/dist/cpsat/semantic-time.d.ts +2 -0
  69. package/dist/cpsat/semantic-time.d.ts.map +1 -1
  70. package/dist/cpsat/semantic-time.js +2 -4
  71. package/dist/cpsat/semantic-time.js.map +1 -1
  72. package/dist/cpsat/types.d.ts +22 -6
  73. package/dist/cpsat/types.d.ts.map +1 -1
  74. package/dist/cpsat/utils.d.ts +1 -1
  75. package/dist/cpsat/utils.js +1 -1
  76. package/dist/cpsat/validation-reporter.js +1 -1
  77. package/dist/cpsat/validation-reporter.js.map +1 -1
  78. package/dist/datetime.utils.d.ts +14 -14
  79. package/dist/datetime.utils.d.ts.map +1 -1
  80. package/dist/datetime.utils.js +26 -27
  81. package/dist/datetime.utils.js.map +1 -1
  82. package/dist/index.d.ts +4 -3
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -2
  85. package/dist/index.js.map +1 -1
  86. package/dist/llms.d.ts +1 -1
  87. package/dist/llms.d.ts.map +1 -1
  88. package/dist/llms.js +1 -1
  89. package/dist/llms.js.map +1 -1
  90. package/dist/testing/index.d.ts +1 -1
  91. package/dist/testing/index.js +1 -1
  92. package/dist/testing/solver-container.js +3 -1
  93. package/dist/testing/solver-container.js.map +1 -1
  94. package/dist/types.d.ts +18 -20
  95. package/dist/types.d.ts.map +1 -1
  96. package/llms.txt +516 -263
  97. package/package.json +25 -25
  98. package/src/client.types.ts +58 -0
  99. package/src/cpsat/model-builder.ts +19 -7
  100. package/src/cpsat/response.ts +12 -3
  101. package/src/cpsat/rules/assign-together.ts +7 -0
  102. package/src/cpsat/rules/employee-assignment-priority.ts +28 -128
  103. package/src/cpsat/rules/location-preference.ts +24 -17
  104. package/src/cpsat/rules/max-consecutive-days.ts +19 -15
  105. package/src/cpsat/rules/max-hours-day.ts +29 -119
  106. package/src/cpsat/rules/max-hours-week.ts +42 -135
  107. package/src/cpsat/rules/max-shifts-day.ts +31 -130
  108. package/src/cpsat/rules/min-consecutive-days.ts +19 -15
  109. package/src/cpsat/rules/min-hours-day.ts +19 -15
  110. package/src/cpsat/rules/min-hours-week.ts +28 -26
  111. package/src/cpsat/rules/min-rest-between-shifts.ts +21 -17
  112. package/src/cpsat/rules/resolver.ts +66 -45
  113. package/src/cpsat/rules/scope.types.ts +534 -0
  114. package/src/cpsat/rules/time-off.ts +48 -145
  115. package/src/cpsat/semantic-time.ts +10 -8
  116. package/src/cpsat/types.ts +22 -6
  117. package/src/cpsat/utils.ts +1 -1
  118. package/src/cpsat/validation-reporter.ts +1 -1
  119. package/src/datetime.utils.ts +27 -29
  120. package/src/index.ts +11 -7
  121. package/src/llms.ts +1 -1
  122. package/src/testing/index.ts +1 -1
  123. package/src/testing/solver-container.ts +3 -3
  124. package/src/types.ts +27 -31
  125. package/dist/cpsat/rules/scoping.d.ts +0 -129
  126. package/dist/cpsat/rules/scoping.d.ts.map +0 -1
  127. package/dist/cpsat/rules/scoping.js +0 -190
  128. package/dist/cpsat/rules/scoping.js.map +0 -1
  129. package/src/cpsat/rules/scoping.ts +0 -340
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Type-safe scoping for rule configurations.
3
+ *
4
+ * Uses the `?: never` pattern to enforce mutual exclusivity at compile time:
5
+ * when one scope field is present, the others in the same dimension are typed
6
+ * as `never`, making it a `tsc` error to provide them.
7
+ *
8
+ * Array scope fields use non-empty tuple types (`[T, ...T[]]`) so that
9
+ * empty arrays are compile-time errors.
10
+ *
11
+ * Two dimensions of scoping:
12
+ * - **Entity scope**: who the rule applies to (employees, roles, or skills)
13
+ * - **Time scope**: when the rule applies (date range, specific dates, day of week, or recurring)
14
+ *
15
+ * Each rule declares which scopes it supports via the `entityScope()`,
16
+ * `timeScope()`, and `requiredTimeScope()` builder functions.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // time-off: entity optional, time required
21
+ * const TimeOffSchema = z.object({ priority: PrioritySchema })
22
+ * .and(entityScope(["employees", "roles", "skills"]))
23
+ * .and(requiredTimeScope(["dateRange", "specificDates", "dayOfWeek", "recurring"]));
24
+ *
25
+ * // max-hours-week: both optional
26
+ * const MaxHoursWeekSchema = z.object({ hours: z.number(), priority: PrioritySchema })
27
+ * .and(entityScope(["employees", "roles", "skills"]))
28
+ * .and(timeScope(["dateRange", "specificDates", "dayOfWeek", "recurring"]));
29
+ *
30
+ * // max-consecutive-days: only employee scoping, no time
31
+ * const MaxConsecutiveDaysSchema = z.object({ days: z.number(), priority: PrioritySchema })
32
+ * .and(entityScope(["employees"]));
33
+ * ```
34
+ */
35
+
36
+ import * as z from "zod";
37
+ import { DayOfWeekSchema, type DayOfWeek } from "../../types.js";
38
+ import type { SchedulingEmployee } from "../types.js";
39
+ import { parseDayString } from "../utils.js";
40
+
41
+ // ============================================================================
42
+ // Scope Keys
43
+ // ============================================================================
44
+
45
+ export type EntityKey = "employees" | "roles" | "skills";
46
+ export type TimeKey = "dateRange" | "specificDates" | "dayOfWeek" | "recurring";
47
+
48
+ // ============================================================================
49
+ // Active / Inactive Field Maps
50
+ // ============================================================================
51
+
52
+ /**
53
+ * The field shape when an entity scope variant is active (selected).
54
+ * Each variant adds a single field with a non-empty array.
55
+ */
56
+ type ActiveEntityFields = {
57
+ employees: { employeeIds: NonEmptyArray<string> };
58
+ roles: { roleIds: NonEmptyArray<string> };
59
+ skills: { skillIds: NonEmptyArray<string> };
60
+ };
61
+
62
+ /**
63
+ * The field shape when an entity scope variant is inactive (not selected).
64
+ * The field is typed as `?: never` so it cannot be provided.
65
+ */
66
+ type InactiveEntityFields = {
67
+ employees: { employeeIds?: never };
68
+ roles: { roleIds?: never };
69
+ skills: { skillIds?: never };
70
+ };
71
+
72
+ type NonEmptyArray<T> = [T, ...T[]];
73
+
74
+ /** Recurring calendar period for time scoping. */
75
+ export interface RecurringPeriod {
76
+ name: string;
77
+ startMonth: number;
78
+ startDay: number;
79
+ endMonth: number;
80
+ endDay: number;
81
+ }
82
+
83
+ /**
84
+ * The field shape when a time scope variant is active (selected).
85
+ */
86
+ type ActiveTimeFields = {
87
+ dateRange: { dateRange: { start: string; end: string } };
88
+ specificDates: { specificDates: NonEmptyArray<string> };
89
+ dayOfWeek: { dayOfWeek: NonEmptyArray<DayOfWeek> };
90
+ recurring: { recurringPeriods: NonEmptyArray<RecurringPeriod> };
91
+ };
92
+
93
+ /**
94
+ * The field shape when a time scope variant is inactive (not selected).
95
+ */
96
+ type InactiveTimeFields = {
97
+ dateRange: { dateRange?: never };
98
+ specificDates: { specificDates?: never };
99
+ dayOfWeek: { dayOfWeek?: never };
100
+ recurring: { recurringPeriods?: never };
101
+ };
102
+
103
+ // ============================================================================
104
+ // Exclusive Union Utility Types
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Merges all values of an object type into an intersection.
109
+ *
110
+ * @example
111
+ * MergeValues<{ a: { x: 1 }; b: { y: 2 } }> = { x: 1 } & { y: 2 }
112
+ */
113
+ type MergeValues<T> = { [K in keyof T]: (x: T[K]) => void } extends {
114
+ [K in keyof T]: (x: infer U) => void;
115
+ }
116
+ ? U
117
+ : never;
118
+
119
+ /**
120
+ * Exactly one of the specified keys must be present.
121
+ * The active key's field is required; all others are `?: never`.
122
+ *
123
+ * @example
124
+ * ExclusiveOne<ActiveEntityFields, InactiveEntityFields, "employees" | "roles">
125
+ * =
126
+ * | { employeeIds: [string, ...string[]]; roleIds?: never }
127
+ * | { roleIds: [string, ...string[]]; employeeIds?: never }
128
+ */
129
+ export type ExclusiveOne<
130
+ Active extends Record<string, object>,
131
+ Inactive extends Record<string, object>,
132
+ Keys extends keyof Active & keyof Inactive,
133
+ > = {
134
+ [K in Keys]: Active[K] & MergeValues<Pick<Inactive, Exclude<Keys, K>>>;
135
+ }[Keys];
136
+
137
+ /**
138
+ * At most one of the specified keys may be present (or none).
139
+ * Same as {@link ExclusiveOne} plus the case where all fields are `?: never`.
140
+ */
141
+ export type MaybeOne<
142
+ Active extends Record<string, object>,
143
+ Inactive extends Record<string, object>,
144
+ Keys extends keyof Active & keyof Inactive,
145
+ > = ExclusiveOne<Active, Inactive, Keys> | MergeValues<Pick<Inactive, Keys>>;
146
+
147
+ // ============================================================================
148
+ // Convenience Aliases
149
+ // ============================================================================
150
+
151
+ /** Entity scope type for a subset of entity keys (at most one). */
152
+ export type EntityScopeType<K extends EntityKey> = MaybeOne<
153
+ ActiveEntityFields,
154
+ InactiveEntityFields,
155
+ K
156
+ >;
157
+
158
+ /** Time scope type for a subset of time keys (at most one, optional). */
159
+ export type OptionalTimeScopeType<K extends TimeKey> = MaybeOne<
160
+ ActiveTimeFields,
161
+ InactiveTimeFields,
162
+ K
163
+ >;
164
+
165
+ /** Time scope type for a subset of time keys (exactly one, required). */
166
+ export type RequiredTimeScopeType<K extends TimeKey> = ExclusiveOne<
167
+ ActiveTimeFields,
168
+ InactiveTimeFields,
169
+ K
170
+ >;
171
+
172
+ // ============================================================================
173
+ // Zod Schemas for Individual Fields
174
+ // ============================================================================
175
+
176
+ const recurringPeriodSchema = z.object({
177
+ name: z.string(),
178
+ startMonth: z.number(),
179
+ startDay: z.number(),
180
+ endMonth: z.number(),
181
+ endDay: z.number(),
182
+ });
183
+
184
+ const entityFieldSchemas = {
185
+ employees: { employeeIds: z.array(z.string()).nonempty() },
186
+ roles: { roleIds: z.array(z.string()).nonempty() },
187
+ skills: { skillIds: z.array(z.string()).nonempty() },
188
+ } as const;
189
+
190
+ const timeFieldSchemas = {
191
+ dateRange: {
192
+ dateRange: z.object({
193
+ start: z.iso.date(),
194
+ end: z.iso.date(),
195
+ }),
196
+ },
197
+ specificDates: { specificDates: z.array(z.iso.date()).nonempty() },
198
+ dayOfWeek: { dayOfWeek: z.array(DayOfWeekSchema).nonempty() },
199
+ recurring: { recurringPeriods: z.array(recurringPeriodSchema).nonempty() },
200
+ } as const;
201
+
202
+ /** Maps entity keys to their field names. */
203
+ const entityFieldNames = {
204
+ employees: "employeeIds",
205
+ roles: "roleIds",
206
+ skills: "skillIds",
207
+ } as const;
208
+
209
+ type EntityFieldName = (typeof entityFieldNames)[EntityKey];
210
+
211
+ /** Maps time keys to their field names. */
212
+ const timeFieldNames = {
213
+ dateRange: "dateRange",
214
+ specificDates: "specificDates",
215
+ dayOfWeek: "dayOfWeek",
216
+ recurring: "recurringPeriods",
217
+ } as const;
218
+
219
+ type TimeFieldName = (typeof timeFieldNames)[TimeKey];
220
+
221
+ // ============================================================================
222
+ // Scope Builder Functions
223
+ // ============================================================================
224
+
225
+ /**
226
+ * Creates a Zod schema for optional entity scoping (at most one of the
227
+ * specified entity variants).
228
+ *
229
+ * The returned schema accepts flat fields (`employeeIds`, `roleIds`, `skillIds`)
230
+ * but the TypeScript type enforces mutual exclusivity via `?: never`.
231
+ *
232
+ * @param keys - Which entity scope variants to support
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * // Supports all entity scopes
237
+ * entityScope(["employees", "roles", "skills"])
238
+ *
239
+ * // Only employee scoping
240
+ * entityScope(["employees"])
241
+ * ```
242
+ */
243
+ export function entityScope<const K extends readonly EntityKey[]>(
244
+ keys: K,
245
+ ): z.ZodType<EntityScopeType<K[number]>> {
246
+ const shape: Record<string, z.ZodTypeAny> = {};
247
+ for (const key of keys) {
248
+ for (const [fieldName, fieldSchema] of Object.entries(entityFieldSchemas[key])) {
249
+ shape[fieldName] = (fieldSchema as z.ZodTypeAny).optional();
250
+ }
251
+ }
252
+
253
+ const schema = z.object(shape).check((payload) => {
254
+ const values = payload.value as Record<EntityFieldName, unknown>;
255
+ const activeFields = keys.filter((key) => {
256
+ const fieldName = entityFieldNames[key];
257
+ const value = values[fieldName];
258
+ return Array.isArray(value) && value.length > 0;
259
+ });
260
+ if (activeFields.length > 1) {
261
+ const fieldNamesList = keys.map((k) => entityFieldNames[k]).join("/");
262
+ payload.issues.push({
263
+ code: "custom",
264
+ message: `Only one of ${fieldNamesList} is allowed`,
265
+ input: payload.value,
266
+ });
267
+ }
268
+ });
269
+
270
+ return schema as unknown as z.ZodType<EntityScopeType<K[number]>>;
271
+ }
272
+
273
+ /**
274
+ * Creates a Zod schema for optional time scoping (at most one of the
275
+ * specified time variants, or none).
276
+ *
277
+ * @param keys - Which time scope variants to support
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * // Supports all time scopes, all optional
282
+ * timeScope(["dateRange", "specificDates", "dayOfWeek", "recurring"])
283
+ * ```
284
+ */
285
+ export function timeScope<const K extends readonly TimeKey[]>(
286
+ keys: K,
287
+ ): z.ZodType<OptionalTimeScopeType<K[number]>> {
288
+ const shape: Record<string, z.ZodTypeAny> = {};
289
+ for (const key of keys) {
290
+ for (const [fieldName, fieldSchema] of Object.entries(timeFieldSchemas[key])) {
291
+ shape[fieldName] = (fieldSchema as z.ZodTypeAny).optional();
292
+ }
293
+ }
294
+
295
+ const schema = z.object(shape).check((payload) => {
296
+ const values = payload.value as Record<TimeFieldName, unknown>;
297
+ const activeFields = keys.filter((key) => {
298
+ const fieldName = timeFieldNames[key];
299
+ const value = values[fieldName];
300
+ if (!value) return false;
301
+ if (fieldName === "dateRange") return true;
302
+ return Array.isArray(value) && value.length > 0;
303
+ });
304
+ if (activeFields.length > 1) {
305
+ const fieldNamesList = keys.map((k) => timeFieldNames[k]).join("/");
306
+ payload.issues.push({
307
+ code: "custom",
308
+ message: `Only one of ${fieldNamesList} is allowed`,
309
+ input: payload.value,
310
+ });
311
+ }
312
+ });
313
+
314
+ return schema as unknown as z.ZodType<OptionalTimeScopeType<K[number]>>;
315
+ }
316
+
317
+ /**
318
+ * Creates a Zod schema for required time scoping (exactly one of the
319
+ * specified time variants must be present).
320
+ *
321
+ * @param keys - Which time scope variants to support
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * // Exactly one time scope required (for time-off)
326
+ * requiredTimeScope(["dateRange", "specificDates", "dayOfWeek", "recurring"])
327
+ * ```
328
+ */
329
+ export function requiredTimeScope<const K extends readonly TimeKey[]>(
330
+ keys: K,
331
+ ): z.ZodType<RequiredTimeScopeType<K[number]>> {
332
+ const shape: Record<string, z.ZodTypeAny> = {};
333
+ for (const key of keys) {
334
+ for (const [fieldName, fieldSchema] of Object.entries(timeFieldSchemas[key])) {
335
+ shape[fieldName] = (fieldSchema as z.ZodTypeAny).optional();
336
+ }
337
+ }
338
+
339
+ const schema = z.object(shape).check((payload) => {
340
+ const values = payload.value as Record<TimeFieldName, unknown>;
341
+ const activeFields = keys.filter((key) => {
342
+ const fieldName = timeFieldNames[key];
343
+ const value = values[fieldName];
344
+ if (!value) return false;
345
+ if (fieldName === "dateRange") return true;
346
+ return Array.isArray(value) && value.length > 0;
347
+ });
348
+ if (activeFields.length === 0) {
349
+ const fieldNamesList = keys.map((k) => timeFieldNames[k]).join(", ");
350
+ payload.issues.push({
351
+ code: "custom",
352
+ message: `Must provide time scoping (${fieldNamesList})`,
353
+ input: payload.value,
354
+ });
355
+ }
356
+ if (activeFields.length > 1) {
357
+ const fieldNamesList = keys.map((k) => timeFieldNames[k]).join("/");
358
+ payload.issues.push({
359
+ code: "custom",
360
+ message: `Only one of ${fieldNamesList} is allowed`,
361
+ input: payload.value,
362
+ });
363
+ }
364
+ });
365
+
366
+ return schema as unknown as z.ZodType<RequiredTimeScopeType<K[number]>>;
367
+ }
368
+
369
+ // ============================================================================
370
+ // Scope Resolution (Shared)
371
+ // ============================================================================
372
+
373
+ /**
374
+ * Parsed entity scope from a flat config.
375
+ * Used internally by scope resolution functions.
376
+ */
377
+ export type ParsedEntityScope =
378
+ | { type: "global" }
379
+ | { type: "employees"; employeeIds: string[] }
380
+ | { type: "roles"; roleIds: string[] }
381
+ | { type: "skills"; skillIds: string[] };
382
+
383
+ /**
384
+ * Parsed time scope from a flat config.
385
+ * Used internally by scope resolution functions.
386
+ */
387
+ export type ParsedTimeScope =
388
+ | { type: "none" }
389
+ | { type: "dateRange"; start: string; end: string }
390
+ | { type: "specificDates"; dates: string[] }
391
+ | { type: "dayOfWeek"; days: DayOfWeek[] }
392
+ | { type: "recurring"; periods: RecurringPeriod[] };
393
+
394
+ /** Input shape accepted by {@link parseEntityScope}. */
395
+ export interface EntityScopeInput {
396
+ employeeIds?: string[];
397
+ roleIds?: string[];
398
+ skillIds?: string[];
399
+ [key: string]: unknown;
400
+ }
401
+
402
+ /** Input shape accepted by {@link parseTimeScope}. */
403
+ export interface TimeScopeInput {
404
+ dateRange?: { start: string; end: string };
405
+ specificDates?: string[];
406
+ dayOfWeek?: DayOfWeek[];
407
+ recurringPeriods?: RecurringPeriod[];
408
+ [key: string]: unknown;
409
+ }
410
+
411
+ /**
412
+ * Extracts the entity scope from a parsed flat config.
413
+ */
414
+ export function parseEntityScope(config: EntityScopeInput): ParsedEntityScope {
415
+ if (config.employeeIds && config.employeeIds.length > 0) {
416
+ return { type: "employees", employeeIds: config.employeeIds };
417
+ }
418
+ if (config.roleIds && config.roleIds.length > 0) {
419
+ return { type: "roles", roleIds: config.roleIds };
420
+ }
421
+ if (config.skillIds && config.skillIds.length > 0) {
422
+ return { type: "skills", skillIds: config.skillIds };
423
+ }
424
+ return { type: "global" };
425
+ }
426
+
427
+ /**
428
+ * Extracts the time scope from a parsed flat config.
429
+ */
430
+ export function parseTimeScope(config: TimeScopeInput): ParsedTimeScope {
431
+ if (config.dateRange) {
432
+ return { type: "dateRange", start: config.dateRange.start, end: config.dateRange.end };
433
+ }
434
+ if (config.specificDates && config.specificDates.length > 0) {
435
+ return { type: "specificDates", dates: config.specificDates };
436
+ }
437
+ if (config.dayOfWeek && config.dayOfWeek.length > 0) {
438
+ return { type: "dayOfWeek", days: config.dayOfWeek };
439
+ }
440
+ if (config.recurringPeriods && config.recurringPeriods.length > 0) {
441
+ return { type: "recurring", periods: config.recurringPeriods };
442
+ }
443
+ return { type: "none" };
444
+ }
445
+
446
+ /**
447
+ * Resolves which employees a rule applies to based on entity scope.
448
+ */
449
+ export function resolveEmployeesFromScope(
450
+ scope: ParsedEntityScope,
451
+ employees: SchedulingEmployee[],
452
+ ): SchedulingEmployee[] {
453
+ switch (scope.type) {
454
+ case "employees": {
455
+ const idSet = new Set(scope.employeeIds);
456
+ return employees.filter((e) => idSet.has(e.id));
457
+ }
458
+ case "roles":
459
+ return employees.filter((e) => e.roleIds.some((r) => scope.roleIds.includes(r)));
460
+ case "skills":
461
+ return employees.filter((e) => e.skillIds?.some((s) => scope.skillIds.includes(s)));
462
+ case "global":
463
+ default:
464
+ return employees;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Resolves which days a rule applies to based on time scope.
470
+ */
471
+ export function resolveActiveDaysFromScope(scope: ParsedTimeScope, allDays: string[]): string[] {
472
+ switch (scope.type) {
473
+ case "none":
474
+ return allDays;
475
+ case "dateRange":
476
+ return allDays.filter((day) => day >= scope.start && day <= scope.end);
477
+ case "specificDates":
478
+ return allDays.filter((day) => scope.dates.includes(day));
479
+ case "dayOfWeek": {
480
+ const targetDays = new Set(scope.days);
481
+ return allDays.filter((day) => {
482
+ const date = parseDayString(day);
483
+ const dayName = getDayOfWeekName(date.getUTCDay());
484
+ return targetDays.has(dayName);
485
+ });
486
+ }
487
+ case "recurring":
488
+ return allDays.filter((day) => {
489
+ const date = parseDayString(day);
490
+ const month = date.getUTCMonth() + 1;
491
+ const dayOfMonth = date.getUTCDate();
492
+ return scope.periods.some((period) => isDateInRecurringPeriod(month, dayOfMonth, period));
493
+ });
494
+ }
495
+ }
496
+
497
+ // ============================================================================
498
+ // Internal Helpers
499
+ // ============================================================================
500
+
501
+ type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
502
+
503
+ function getDayOfWeekName(dayIndex: number): DayName {
504
+ const names: Record<number, DayName> = {
505
+ 0: "sunday",
506
+ 1: "monday",
507
+ 2: "tuesday",
508
+ 3: "wednesday",
509
+ 4: "thursday",
510
+ 5: "friday",
511
+ 6: "saturday",
512
+ };
513
+ return names[dayIndex % 7] ?? "sunday";
514
+ }
515
+
516
+ function isDateInRecurringPeriod(
517
+ month: number,
518
+ dayOfMonth: number,
519
+ period: RecurringPeriod,
520
+ ): boolean {
521
+ const { startMonth, startDay, endMonth, endDay } = period;
522
+
523
+ if (startMonth <= endMonth) {
524
+ if (month < startMonth || month > endMonth) return false;
525
+ if (month === startMonth && dayOfMonth < startDay) return false;
526
+ if (month === endMonth && dayOfMonth > endDay) return false;
527
+ return true;
528
+ } else {
529
+ if (month > endMonth && month < startMonth) return false;
530
+ if (month === startMonth && dayOfMonth < startDay) return false;
531
+ if (month === endMonth && dayOfMonth > endDay) return false;
532
+ return true;
533
+ }
534
+ }