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.
- package/CHANGELOG.md +23 -0
- package/README.md +6 -3
- package/dist/cpsat/rules/index.d.ts +3 -0
- package/dist/cpsat/rules/index.d.ts.map +1 -1
- package/dist/cpsat/rules/index.js +3 -0
- package/dist/cpsat/rules/index.js.map +1 -1
- package/dist/cpsat/rules/max-days-week.d.ts +44 -0
- package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-days-week.js +95 -0
- package/dist/cpsat/rules/max-days-week.js.map +1 -0
- package/dist/cpsat/rules/min-days-week.d.ts +34 -0
- package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-days-week.js +84 -0
- package/dist/cpsat/rules/min-days-week.js.map +1 -0
- package/dist/cpsat/rules/must-assign.d.ts +49 -0
- package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
- package/dist/cpsat/rules/must-assign.js +86 -0
- package/dist/cpsat/rules/must-assign.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +4 -1
- package/dist/cpsat/rules/registry.d.ts.map +1 -1
- package/dist/cpsat/rules/registry.js +4 -1
- package/dist/cpsat/rules/registry.js.map +1 -1
- package/dist/cpsat/rules/rules.types.d.ts +3 -0
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
- package/dist/cpsat/rules/scope.types.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/schedule/cost.d.ts +204 -0
- package/dist/schedule/cost.d.ts.map +1 -0
- package/dist/schedule/cost.js +187 -0
- package/dist/schedule/cost.js.map +1 -0
- package/dist/schedule/coverage.d.ts +85 -0
- package/dist/schedule/coverage.d.ts.map +1 -0
- package/dist/schedule/coverage.js +33 -0
- package/dist/schedule/coverage.js.map +1 -0
- package/dist/schedule/definition.d.ts +227 -0
- package/dist/schedule/definition.d.ts.map +1 -0
- package/dist/{schedule.js → schedule/definition.js} +9 -673
- package/dist/schedule/definition.js.map +1 -0
- package/dist/schedule/index.d.ts +67 -0
- package/dist/schedule/index.d.ts.map +1 -0
- package/dist/schedule/index.js +69 -0
- package/dist/schedule/index.js.map +1 -0
- package/dist/schedule/rules.d.ts +353 -0
- package/dist/schedule/rules.d.ts.map +1 -0
- package/dist/schedule/rules.js +352 -0
- package/dist/schedule/rules.js.map +1 -0
- package/dist/schedule/shift-patterns.d.ts +34 -0
- package/dist/schedule/shift-patterns.d.ts.map +1 -0
- package/dist/schedule/shift-patterns.js +41 -0
- package/dist/schedule/shift-patterns.js.map +1 -0
- package/dist/schedule/time-periods.d.ts +69 -0
- package/dist/schedule/time-periods.d.ts.map +1 -0
- package/dist/schedule/time-periods.js +91 -0
- package/dist/schedule/time-periods.js.map +1 -0
- package/package.json +4 -9
- package/src/cpsat/rules/index.ts +3 -0
- package/src/cpsat/rules/max-days-week.ts +143 -0
- package/src/cpsat/rules/min-days-week.ts +120 -0
- package/src/cpsat/rules/must-assign.ts +108 -0
- package/src/cpsat/rules/registry.ts +6 -0
- package/src/cpsat/rules/rules.types.ts +3 -0
- package/src/cpsat/rules/scope.types.ts +1 -1
- package/src/index.ts +8 -3
- package/src/schedule/cost.ts +242 -0
- package/src/schedule/coverage.ts +135 -0
- package/src/schedule/definition.ts +958 -0
- package/src/schedule/index.ts +112 -0
- package/src/schedule/rules.ts +529 -0
- package/src/schedule/shift-patterns.ts +46 -0
- package/src/schedule/time-periods.ts +110 -0
- package/dist/llms.d.ts +0 -2
- package/dist/llms.d.ts.map +0 -1
- package/dist/llms.js +0 -3
- package/dist/llms.js.map +0 -1
- package/dist/schedule.d.ts +0 -917
- package/dist/schedule.d.ts.map +0 -1
- package/dist/schedule.js.map +0 -1
- package/llms.txt +0 -758
- package/src/llms.ts +0 -3
- 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
|
+
}
|