dabke 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +42 -0
- package/dist/client.js.map +1 -0
- package/dist/client.schemas.d.ts +250 -0
- package/dist/client.schemas.d.ts.map +1 -0
- package/dist/client.schemas.js +137 -0
- package/dist/client.schemas.js.map +1 -0
- package/dist/client.types.d.ts +34 -0
- package/dist/client.types.d.ts.map +1 -0
- package/dist/client.types.js +18 -0
- package/dist/client.types.js.map +1 -0
- package/dist/cpsat/model-builder.d.ts +128 -0
- package/dist/cpsat/model-builder.d.ts.map +1 -0
- package/dist/cpsat/model-builder.js +640 -0
- package/dist/cpsat/model-builder.js.map +1 -0
- package/dist/cpsat/response.d.ts +74 -0
- package/dist/cpsat/response.d.ts.map +1 -0
- package/dist/cpsat/response.js +92 -0
- package/dist/cpsat/response.js.map +1 -0
- package/dist/cpsat/rules/assign-together.d.ts +23 -0
- package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
- package/dist/cpsat/rules/assign-together.js +78 -0
- package/dist/cpsat/rules/assign-together.js.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
- package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
- package/dist/cpsat/rules/index.d.ts +13 -0
- package/dist/cpsat/rules/index.d.ts.map +1 -0
- package/dist/cpsat/rules/index.js +13 -0
- package/dist/cpsat/rules/index.js.map +1 -0
- package/dist/cpsat/rules/location-preference.d.ts +29 -0
- package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
- package/dist/cpsat/rules/location-preference.js +59 -0
- package/dist/cpsat/rules/location-preference.js.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.js +70 -0
- package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
- package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-day.js +159 -0
- package/dist/cpsat/rules/max-hours-day.js.map +1 -0
- package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
- package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-week.js +169 -0
- package/dist/cpsat/rules/max-hours-week.js.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.js +170 -0
- package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.js +104 -0
- package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
- package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-day.js +61 -0
- package/dist/cpsat/rules/min-hours-day.js.map +1 -0
- package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
- package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-week.js +68 -0
- package/dist/cpsat/rules/min-hours-week.js.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +7 -0
- package/dist/cpsat/rules/registry.d.ts.map +1 -0
- package/dist/cpsat/rules/registry.js +28 -0
- package/dist/cpsat/rules/registry.js.map +1 -0
- package/dist/cpsat/rules/resolver.d.ts +31 -0
- package/dist/cpsat/rules/resolver.d.ts.map +1 -0
- package/dist/cpsat/rules/resolver.js +124 -0
- package/dist/cpsat/rules/resolver.js.map +1 -0
- package/dist/cpsat/rules/rules.types.d.ts +32 -0
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
- package/dist/cpsat/rules/rules.types.js +2 -0
- package/dist/cpsat/rules/rules.types.js.map +1 -0
- package/dist/cpsat/rules/scoping.d.ts +129 -0
- package/dist/cpsat/rules/scoping.d.ts.map +1 -0
- package/dist/cpsat/rules/scoping.js +190 -0
- package/dist/cpsat/rules/scoping.js.map +1 -0
- package/dist/cpsat/rules/time-off.d.ts +78 -0
- package/dist/cpsat/rules/time-off.d.ts.map +1 -0
- package/dist/cpsat/rules/time-off.js +261 -0
- package/dist/cpsat/rules/time-off.js.map +1 -0
- package/dist/cpsat/rules.d.ts +5 -0
- package/dist/cpsat/rules.d.ts.map +1 -0
- package/dist/cpsat/rules.js +4 -0
- package/dist/cpsat/rules.js.map +1 -0
- package/dist/cpsat/semantic-time.d.ts +198 -0
- package/dist/cpsat/semantic-time.d.ts.map +1 -0
- package/dist/cpsat/semantic-time.js +222 -0
- package/dist/cpsat/semantic-time.js.map +1 -0
- package/dist/cpsat/types.d.ts +180 -0
- package/dist/cpsat/types.d.ts.map +1 -0
- package/dist/cpsat/types.js +2 -0
- package/dist/cpsat/types.js.map +1 -0
- package/dist/cpsat/utils.d.ts +47 -0
- package/dist/cpsat/utils.d.ts.map +1 -0
- package/dist/cpsat/utils.js +92 -0
- package/dist/cpsat/utils.js.map +1 -0
- package/dist/cpsat/validation-reporter.d.ts +54 -0
- package/dist/cpsat/validation-reporter.d.ts.map +1 -0
- package/dist/cpsat/validation-reporter.js +261 -0
- package/dist/cpsat/validation-reporter.js.map +1 -0
- package/dist/cpsat/validation.types.d.ts +141 -0
- package/dist/cpsat/validation.types.d.ts.map +1 -0
- package/dist/cpsat/validation.types.js +14 -0
- package/dist/cpsat/validation.types.js.map +1 -0
- package/dist/datetime.utils.d.ts +245 -0
- package/dist/datetime.utils.d.ts.map +1 -0
- package/dist/datetime.utils.js +372 -0
- package/dist/datetime.utils.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/llms.d.ts +5 -0
- package/dist/llms.d.ts.map +1 -0
- package/dist/llms.js +8 -0
- package/dist/llms.js.map +1 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/solver-container.d.ts +49 -0
- package/dist/testing/solver-container.d.ts.map +1 -0
- package/dist/testing/solver-container.js +127 -0
- package/dist/testing/solver-container.js.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +105 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +130 -0
- package/dist/validation.js.map +1 -0
- package/llms.txt +2188 -0
- package/package.json +76 -0
- package/solver/Dockerfile +31 -0
- package/solver/README.md +23 -0
- package/solver/pyproject.toml +28 -0
- package/solver/src/solver/__init__.py +1 -0
- package/solver/src/solver/app.py +24 -0
- package/solver/src/solver/models.py +120 -0
- package/solver/src/solver/solver.py +359 -0
- package/solver/tests/test_solver.py +156 -0
- package/solver/uv.lock +661 -0
- package/src/client.schemas.ts +163 -0
- package/src/client.ts +67 -0
- package/src/client.types.ts +66 -0
- package/src/cpsat/model-builder.ts +858 -0
- package/src/cpsat/response.ts +130 -0
- package/src/cpsat/rules/assign-together.ts +96 -0
- package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
- package/src/cpsat/rules/index.ts +12 -0
- package/src/cpsat/rules/location-preference.ts +68 -0
- package/src/cpsat/rules/max-consecutive-days.ts +98 -0
- package/src/cpsat/rules/max-hours-day.ts +187 -0
- package/src/cpsat/rules/max-hours-week.ts +197 -0
- package/src/cpsat/rules/max-shifts-day.ts +198 -0
- package/src/cpsat/rules/min-consecutive-days.ts +140 -0
- package/src/cpsat/rules/min-hours-day.ts +69 -0
- package/src/cpsat/rules/min-hours-week.ts +77 -0
- package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
- package/src/cpsat/rules/registry.ts +49 -0
- package/src/cpsat/rules/resolver.ts +181 -0
- package/src/cpsat/rules/rules.types.ts +41 -0
- package/src/cpsat/rules/scoping.ts +340 -0
- package/src/cpsat/rules/time-off.ts +336 -0
- package/src/cpsat/rules.ts +27 -0
- package/src/cpsat/semantic-time.ts +463 -0
- package/src/cpsat/types.ts +194 -0
- package/src/cpsat/utils.ts +105 -0
- package/src/cpsat/validation-reporter.ts +366 -0
- package/src/cpsat/validation.types.ts +185 -0
- package/src/datetime.utils.ts +426 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +289 -0
- package/src/llms.ts +9 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/solver-container.ts +172 -0
- package/src/types.ts +191 -0
- package/src/validation.ts +188 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { resolveDaysFromPeriod, toDayOfWeekUTC } from "../datetime.utils.js";
|
|
2
|
+
import { MINUTES_PER_DAY, normalizeEndMinutes, OBJECTIVE_WEIGHTS, parseDayString, priorityToPenalty, timeOfDayToMinutes, } from "./utils.js";
|
|
3
|
+
import { buildCpsatRules } from "./rules/resolver.js";
|
|
4
|
+
import { builtInCpsatRuleFactories } from "./rules/registry.js";
|
|
5
|
+
import { ValidationReporterImpl } from "./validation-reporter.js";
|
|
6
|
+
/**
|
|
7
|
+
* Compilation context that creates variables, constraints, and objectives
|
|
8
|
+
* and emits a `SolverRequest` for the Python CP-SAT solver service.
|
|
9
|
+
*/
|
|
10
|
+
export class ModelBuilder {
|
|
11
|
+
employees;
|
|
12
|
+
shiftPatterns;
|
|
13
|
+
days;
|
|
14
|
+
coverage;
|
|
15
|
+
rules;
|
|
16
|
+
weekStartsOn;
|
|
17
|
+
options;
|
|
18
|
+
coverageBucketMinutes;
|
|
19
|
+
reporter;
|
|
20
|
+
fairDistribution;
|
|
21
|
+
#variables = new Map();
|
|
22
|
+
#constraints = [];
|
|
23
|
+
#objective = [];
|
|
24
|
+
#dayIndex = new Map();
|
|
25
|
+
#shiftPatternMap = new Map();
|
|
26
|
+
#builtRequest;
|
|
27
|
+
#builtCompilation;
|
|
28
|
+
constructor(config) {
|
|
29
|
+
// Validate IDs don't contain the separator character
|
|
30
|
+
for (const emp of config.employees) {
|
|
31
|
+
if (emp.id.includes(":")) {
|
|
32
|
+
throw new Error(`Employee ID "${emp.id}" cannot contain colons`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const pattern of config.shiftPatterns) {
|
|
36
|
+
if (pattern.id.includes(":")) {
|
|
37
|
+
throw new Error(`Shift pattern ID "${pattern.id}" cannot contain colons`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Validate coverage requirements have at least roleIds or skillIds
|
|
41
|
+
for (const cov of config.coverage) {
|
|
42
|
+
const hasRoles = cov.roleIds !== undefined && cov.roleIds.length > 0;
|
|
43
|
+
const hasSkills = cov.skillIds !== undefined && cov.skillIds.length > 0;
|
|
44
|
+
if (!hasRoles && !hasSkills) {
|
|
45
|
+
throw new Error(`Coverage requirement for day "${cov.day}" must have at least one of roleIds or skillIds`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.employees = config.employees;
|
|
49
|
+
this.shiftPatterns = config.shiftPatterns;
|
|
50
|
+
this.days = resolveDaysFromPeriod(config.schedulingPeriod);
|
|
51
|
+
this.coverage = config.coverage;
|
|
52
|
+
const compiledRuleConfigs = config.ruleConfigs
|
|
53
|
+
? buildCpsatRules(config.ruleConfigs, this.employees, config.ruleFactories ?? builtInCpsatRuleFactories)
|
|
54
|
+
: [];
|
|
55
|
+
this.rules = [...compiledRuleConfigs, ...(config.rules ?? [])];
|
|
56
|
+
this.weekStartsOn = config.weekStartsOn ?? "monday";
|
|
57
|
+
this.options = config.solverOptions;
|
|
58
|
+
this.coverageBucketMinutes = config.coverageBucketMinutes ?? 15;
|
|
59
|
+
this.reporter = config.reporter ?? new ValidationReporterImpl();
|
|
60
|
+
this.fairDistribution = config.fairDistribution ?? true;
|
|
61
|
+
this.days.forEach((day, idx) => this.#dayIndex.set(day, idx));
|
|
62
|
+
this.shiftPatterns.forEach((pattern) => this.#shiftPatternMap.set(pattern.id, pattern));
|
|
63
|
+
}
|
|
64
|
+
boolVar(name) {
|
|
65
|
+
const existing = this.#variables.get(name);
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (existing.type !== "bool") {
|
|
68
|
+
throw new Error(`Variable ${name} already exists with different type`);
|
|
69
|
+
}
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
this.#variables.set(name, { type: "bool", name });
|
|
73
|
+
return name;
|
|
74
|
+
}
|
|
75
|
+
intVar(name, min, max) {
|
|
76
|
+
const existing = this.#variables.get(name);
|
|
77
|
+
if (existing) {
|
|
78
|
+
if (existing.type !== "int" || existing.min !== min || existing.max !== max) {
|
|
79
|
+
throw new Error(`Variable ${name} already exists with different bounds`);
|
|
80
|
+
}
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
this.#variables.set(name, { type: "int", name, min, max });
|
|
84
|
+
return name;
|
|
85
|
+
}
|
|
86
|
+
shiftActive(patternId, day) {
|
|
87
|
+
return this.boolVar(`shift:${patternId}:${day}`);
|
|
88
|
+
}
|
|
89
|
+
assignment(employeeId, patternId, day) {
|
|
90
|
+
return this.boolVar(`assign:${employeeId}:${patternId}:${day}`);
|
|
91
|
+
}
|
|
92
|
+
addLinear(terms, op, rhs) {
|
|
93
|
+
this.#constraints.push({ type: "linear", terms, op, rhs });
|
|
94
|
+
}
|
|
95
|
+
addSoftLinear(terms, op, rhs, penalty, id) {
|
|
96
|
+
this.#constraints.push({ type: "soft_linear", terms, op, rhs, penalty, id });
|
|
97
|
+
}
|
|
98
|
+
addExactlyOne(vars) {
|
|
99
|
+
if (vars.length === 0)
|
|
100
|
+
return;
|
|
101
|
+
this.#constraints.push({ type: "exactly_one", vars });
|
|
102
|
+
}
|
|
103
|
+
addAtMostOne(vars) {
|
|
104
|
+
if (vars.length === 0)
|
|
105
|
+
return;
|
|
106
|
+
this.#constraints.push({ type: "at_most_one", vars });
|
|
107
|
+
}
|
|
108
|
+
addImplication(ifVar, thenVar) {
|
|
109
|
+
// oxlint-disable-next-line unicorn/no-thenable -- This is a constraint property, not a Promise
|
|
110
|
+
this.#constraints.push({ type: "implication", if: ifVar, then: thenVar });
|
|
111
|
+
}
|
|
112
|
+
addBoolOr(vars) {
|
|
113
|
+
if (vars.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
this.#constraints.push({ type: "bool_or", vars });
|
|
116
|
+
}
|
|
117
|
+
addBoolAnd(vars) {
|
|
118
|
+
if (vars.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
this.#constraints.push({ type: "bool_and", vars });
|
|
121
|
+
}
|
|
122
|
+
intervalVar(name, start, end, size, presenceVar) {
|
|
123
|
+
const existing = this.#variables.get(name);
|
|
124
|
+
if (existing) {
|
|
125
|
+
if (existing.type !== "interval") {
|
|
126
|
+
throw new Error(`Variable ${name} already exists with different type`);
|
|
127
|
+
}
|
|
128
|
+
if (existing.start !== start ||
|
|
129
|
+
existing.end !== end ||
|
|
130
|
+
existing.size !== size ||
|
|
131
|
+
existing.presenceVar !== presenceVar) {
|
|
132
|
+
throw new Error(`Variable ${name} already exists with different parameters`);
|
|
133
|
+
}
|
|
134
|
+
return name;
|
|
135
|
+
}
|
|
136
|
+
this.#variables.set(name, {
|
|
137
|
+
type: "interval",
|
|
138
|
+
name,
|
|
139
|
+
start,
|
|
140
|
+
end,
|
|
141
|
+
size,
|
|
142
|
+
presenceVar,
|
|
143
|
+
});
|
|
144
|
+
return name;
|
|
145
|
+
}
|
|
146
|
+
addNoOverlap(intervals) {
|
|
147
|
+
if (intervals.length === 0)
|
|
148
|
+
return;
|
|
149
|
+
this.#constraints.push({ type: "no_overlap", intervals });
|
|
150
|
+
}
|
|
151
|
+
addPenalty(varName, weight) {
|
|
152
|
+
if (weight === 0)
|
|
153
|
+
return;
|
|
154
|
+
this.#objective.push({ var: varName, coeff: weight });
|
|
155
|
+
}
|
|
156
|
+
employeesWithRole(roleId) {
|
|
157
|
+
return this.employees.filter((emp) => emp.roleIds.includes(roleId));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Returns team members who can satisfy a coverage requirement.
|
|
161
|
+
*
|
|
162
|
+
* Matching logic:
|
|
163
|
+
* - If only roleId: must have that role
|
|
164
|
+
* - If only skillIds: must have ALL specified skills
|
|
165
|
+
* - If both: must have the role AND ALL specified skills
|
|
166
|
+
*/
|
|
167
|
+
employeesForCoverage(cov) {
|
|
168
|
+
return this.employees.filter((emp) => {
|
|
169
|
+
// Check role requirement if specified (OR logic - must have ANY of the roles)
|
|
170
|
+
if (cov.roleIds && cov.roleIds.length > 0) {
|
|
171
|
+
const hasMatchingRole = cov.roleIds.some((role) => emp.roleIds.includes(role));
|
|
172
|
+
if (!hasMatchingRole) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Check skill requirements if specified (AND logic - must have ALL skills)
|
|
177
|
+
if (cov.skillIds && cov.skillIds.length > 0) {
|
|
178
|
+
const empSkills = emp.skillIds ?? [];
|
|
179
|
+
if (!cov.skillIds.every((skill) => empSkills.includes(skill))) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
canAssign(employee, pattern) {
|
|
187
|
+
// If pattern has no roleIds, anyone can work it
|
|
188
|
+
if (!pattern.roleIds || pattern.roleIds.length === 0) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
// Otherwise, must have at least one matching role
|
|
192
|
+
return pattern.roleIds.some((roleId) => employee.roleIds.includes(roleId));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Checks if a shift pattern can be used on a specific day.
|
|
196
|
+
* Returns false if the pattern has daysOfWeek restrictions that exclude this day.
|
|
197
|
+
*/
|
|
198
|
+
patternAvailableOnDay(pattern, day) {
|
|
199
|
+
// If pattern has no day restrictions, it's available every day
|
|
200
|
+
if (!pattern.daysOfWeek || pattern.daysOfWeek.length === 0) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
// Check if this day's day-of-week is in the allowed list
|
|
204
|
+
const date = parseDayString(day);
|
|
205
|
+
const dayOfWeek = toDayOfWeekUTC(date);
|
|
206
|
+
return pattern.daysOfWeek.includes(dayOfWeek);
|
|
207
|
+
}
|
|
208
|
+
patternDuration(patternId) {
|
|
209
|
+
const pattern = this.#shiftPatternMap.get(patternId);
|
|
210
|
+
if (!pattern)
|
|
211
|
+
throw new Error(`Unknown pattern ${patternId}`);
|
|
212
|
+
const start = timeOfDayToMinutes(pattern.startTime);
|
|
213
|
+
const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
|
|
214
|
+
return end - start;
|
|
215
|
+
}
|
|
216
|
+
startMinutes(pattern, day) {
|
|
217
|
+
const base = this.#dayOffset(day);
|
|
218
|
+
return base + timeOfDayToMinutes(pattern.startTime);
|
|
219
|
+
}
|
|
220
|
+
endMinutes(pattern, day) {
|
|
221
|
+
const base = this.#dayOffset(day);
|
|
222
|
+
const startOffset = timeOfDayToMinutes(pattern.startTime);
|
|
223
|
+
const endOffset = normalizeEndMinutes(startOffset, timeOfDayToMinutes(pattern.endTime));
|
|
224
|
+
return base + endOffset;
|
|
225
|
+
}
|
|
226
|
+
compile() {
|
|
227
|
+
if (this.#builtCompilation) {
|
|
228
|
+
return this.#builtCompilation;
|
|
229
|
+
}
|
|
230
|
+
// 0. Apply all rules first (they may report exclusions and add constraints)
|
|
231
|
+
for (const rule of this.rules) {
|
|
232
|
+
rule.compile(this);
|
|
233
|
+
}
|
|
234
|
+
// Build exclusion lookup from reporter (populated by rules during compile)
|
|
235
|
+
const mandatoryExclusions = buildExclusionLookup(this.reporter.getExclusions());
|
|
236
|
+
// 1. Assignment implies shift is active
|
|
237
|
+
for (const emp of this.employees) {
|
|
238
|
+
for (const pattern of this.shiftPatterns) {
|
|
239
|
+
if (!this.canAssign(emp, pattern))
|
|
240
|
+
continue;
|
|
241
|
+
for (const day of this.days) {
|
|
242
|
+
if (!this.patternAvailableOnDay(pattern, day))
|
|
243
|
+
continue;
|
|
244
|
+
this.addImplication(this.assignment(emp.id, pattern.id, day), this.shiftActive(pattern.id, day));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// 1b. Build optional interval variables for assignments and prevent overlaps.
|
|
249
|
+
// One optional interval per (person, pattern, day) assignment.
|
|
250
|
+
for (const emp of this.employees) {
|
|
251
|
+
const empIntervals = [];
|
|
252
|
+
for (const pattern of this.shiftPatterns) {
|
|
253
|
+
if (!this.canAssign(emp, pattern))
|
|
254
|
+
continue;
|
|
255
|
+
for (const day of this.days) {
|
|
256
|
+
if (!this.patternAvailableOnDay(pattern, day))
|
|
257
|
+
continue;
|
|
258
|
+
const presenceVar = this.assignment(emp.id, pattern.id, day);
|
|
259
|
+
const start = this.startMinutes(pattern, day);
|
|
260
|
+
const end = this.endMinutes(pattern, day);
|
|
261
|
+
const size = end - start;
|
|
262
|
+
const intervalName = `interval:${emp.id}:${pattern.id}:${day}`;
|
|
263
|
+
this.intervalVar(intervalName, start, end, size, presenceVar);
|
|
264
|
+
empIntervals.push(intervalName);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.addNoOverlap(empIntervals);
|
|
268
|
+
}
|
|
269
|
+
// 2. Coverage requirements (bucketed, time-indexed)
|
|
270
|
+
// Coverage requirements are expressed independently from shift patterns.
|
|
271
|
+
// We discretize each requirement into fixed-size buckets (default: 15 minutes)
|
|
272
|
+
// and ensure enough people are working in EACH bucket.
|
|
273
|
+
//
|
|
274
|
+
// This supports staggered overlapping shifts, where multiple patterns together
|
|
275
|
+
// can satisfy the coverage window.
|
|
276
|
+
const bucket = this.coverageBucketMinutes;
|
|
277
|
+
const allowedBuckets = new Set([5, 10, 15, 30, 60]);
|
|
278
|
+
if (!Number.isInteger(bucket) || !allowedBuckets.has(bucket)) {
|
|
279
|
+
throw new Error(`coverageBucketMinutes must be one of ${[...allowedBuckets].join(", ")}, got ${bucket}`);
|
|
280
|
+
}
|
|
281
|
+
// Precompute pattern time ranges and which patterns overlap each bucket start.
|
|
282
|
+
// Patterns are day-invariant (same time-of-day every day), so we can compute once.
|
|
283
|
+
const patternRanges = this.shiftPatterns.map((p) => {
|
|
284
|
+
const start = timeOfDayToMinutes(p.startTime);
|
|
285
|
+
const end = normalizeEndMinutes(start, timeOfDayToMinutes(p.endTime));
|
|
286
|
+
return { pattern: p, start, end };
|
|
287
|
+
});
|
|
288
|
+
const patternsByBucketStart = new Map();
|
|
289
|
+
for (let t = 0; t < MINUTES_PER_DAY; t += bucket) {
|
|
290
|
+
const bucketStart = t;
|
|
291
|
+
const bucketEnd = Math.min(t + bucket, MINUTES_PER_DAY);
|
|
292
|
+
const patterns = [];
|
|
293
|
+
for (const { pattern, start, end } of patternRanges) {
|
|
294
|
+
// Standard overlap check
|
|
295
|
+
const standardOverlap = Math.max(start, bucketStart) < Math.min(end, bucketEnd);
|
|
296
|
+
// Overnight pattern wrap-around check: if pattern ends past midnight (end > MINUTES_PER_DAY),
|
|
297
|
+
// the bucket also overlaps if it falls in the early morning portion (0 to end - MINUTES_PER_DAY)
|
|
298
|
+
const wrapAroundOverlap = end > MINUTES_PER_DAY && bucketStart < end - MINUTES_PER_DAY;
|
|
299
|
+
if (standardOverlap || wrapAroundOverlap)
|
|
300
|
+
patterns.push(pattern);
|
|
301
|
+
}
|
|
302
|
+
patternsByBucketStart.set(bucketStart, patterns);
|
|
303
|
+
}
|
|
304
|
+
for (const cov of this.coverage) {
|
|
305
|
+
const covStart = timeOfDayToMinutes(cov.startTime);
|
|
306
|
+
const covEnd = normalizeEndMinutes(covStart, timeOfDayToMinutes(cov.endTime));
|
|
307
|
+
const covKey = cov.roleIds?.join(",") ?? cov.skillIds?.join(",") ?? "unknown";
|
|
308
|
+
const coverageLabel = cov.roleIds && cov.roleIds.length > 0
|
|
309
|
+
? cov.roleIds.length === 1
|
|
310
|
+
? `role "${cov.roleIds[0]}"`
|
|
311
|
+
: `role "${cov.roleIds.join(" or ")}"`
|
|
312
|
+
: `skills [${cov.skillIds?.join(", ")}]`;
|
|
313
|
+
const coverageWindow = formatTimeRange(covStart, covEnd);
|
|
314
|
+
const eligibleEmployees = this.employeesForCoverage(cov);
|
|
315
|
+
if (eligibleEmployees.length === 0) {
|
|
316
|
+
if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
|
|
317
|
+
this.reporter.reportCoverageError({
|
|
318
|
+
day: cov.day,
|
|
319
|
+
timeSlots: [coverageWindow],
|
|
320
|
+
roleIds: cov.roleIds,
|
|
321
|
+
skillIds: cov.skillIds,
|
|
322
|
+
reason: `Coverage for ${coverageLabel} on ${cov.day} (${coverageWindow}) cannot be met: no eligible team members available.`,
|
|
323
|
+
suggestions: [
|
|
324
|
+
cov.roleIds && cov.roleIds.length > 0
|
|
325
|
+
? `Add team members with role "${cov.roleIds.join(" or ")}"`
|
|
326
|
+
: "Add team members with the required skills",
|
|
327
|
+
"Change the coverage requirement to match available team members",
|
|
328
|
+
],
|
|
329
|
+
groupKey: cov.groupKey,
|
|
330
|
+
});
|
|
331
|
+
const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}`;
|
|
332
|
+
this.intVar(impossibleVar, 0, 0);
|
|
333
|
+
this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
|
|
334
|
+
}
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const bucketIssues = new Map();
|
|
338
|
+
for (let t = covStart; t < covEnd; t += bucket) {
|
|
339
|
+
const bucketStart = t;
|
|
340
|
+
const bucketEnd = Math.min(t + bucket, covEnd);
|
|
341
|
+
// For overnight coverage (times >= MINUTES_PER_DAY), wrap around to find
|
|
342
|
+
// which shift patterns overlap. E.g., 25:00 wraps to 01:00.
|
|
343
|
+
const lookupBucketStart = bucketStart % MINUTES_PER_DAY;
|
|
344
|
+
// Get patterns that overlap this time bucket, then filter by day availability
|
|
345
|
+
const allPatterns = patternsByBucketStart.get(lookupBucketStart) ?? [];
|
|
346
|
+
const patterns = allPatterns.filter((p) => this.patternAvailableOnDay(p, cov.day));
|
|
347
|
+
if (patterns.length === 0) {
|
|
348
|
+
recordBucketIssue(bucketIssues, {
|
|
349
|
+
key: "no_patterns",
|
|
350
|
+
severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
|
|
351
|
+
reason: "no shift patterns overlap this time",
|
|
352
|
+
suggestions: [
|
|
353
|
+
"Add shift patterns that overlap this coverage window",
|
|
354
|
+
"Adjust the coverage window to match available shifts",
|
|
355
|
+
],
|
|
356
|
+
}, bucketStart);
|
|
357
|
+
if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
|
|
358
|
+
const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}:${bucketStart}`;
|
|
359
|
+
this.intVar(impossibleVar, 0, 0);
|
|
360
|
+
this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const assignableEmployees = new Set();
|
|
365
|
+
for (const emp of eligibleEmployees) {
|
|
366
|
+
for (const pattern of patterns) {
|
|
367
|
+
if (!this.canAssign(emp, pattern))
|
|
368
|
+
continue;
|
|
369
|
+
assignableEmployees.add(emp.id);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (assignableEmployees.size === 0) {
|
|
374
|
+
recordBucketIssue(bucketIssues, {
|
|
375
|
+
key: "no_assignable",
|
|
376
|
+
severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
|
|
377
|
+
reason: "no eligible team members can work overlapping shift patterns",
|
|
378
|
+
suggestions: [
|
|
379
|
+
"Adjust shift pattern role requirements",
|
|
380
|
+
"Add shift patterns that eligible team members can work",
|
|
381
|
+
],
|
|
382
|
+
}, bucketStart);
|
|
383
|
+
if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
|
|
384
|
+
const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}:${bucketStart}`;
|
|
385
|
+
this.intVar(impossibleVar, 0, 0);
|
|
386
|
+
this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
|
|
387
|
+
}
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const availableEmployees = new Set();
|
|
391
|
+
for (const employeeId of assignableEmployees) {
|
|
392
|
+
const exclusions = mandatoryExclusions.get(`${employeeId}:${cov.day}`) ?? [];
|
|
393
|
+
const blocked = exclusions.some((exclusion) => rangesOverlap(exclusion.startMinutes, exclusion.endMinutes, bucketStart, bucketEnd));
|
|
394
|
+
if (!blocked) {
|
|
395
|
+
availableEmployees.add(employeeId);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (availableEmployees.size === 0) {
|
|
399
|
+
recordBucketIssue(bucketIssues, {
|
|
400
|
+
key: "mandatory_time_off",
|
|
401
|
+
severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
|
|
402
|
+
reason: "all eligible team members are on mandatory time off",
|
|
403
|
+
suggestions: [
|
|
404
|
+
"Adjust mandatory time-off requests",
|
|
405
|
+
"Add more team members with the required role or skills",
|
|
406
|
+
],
|
|
407
|
+
}, bucketStart);
|
|
408
|
+
}
|
|
409
|
+
else if (availableEmployees.size < cov.targetCount) {
|
|
410
|
+
recordBucketIssue(bucketIssues, {
|
|
411
|
+
key: `insufficient:${availableEmployees.size}`,
|
|
412
|
+
severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
|
|
413
|
+
reason: `only ${availableEmployees.size} team members available, need ${cov.targetCount}`,
|
|
414
|
+
suggestions: [
|
|
415
|
+
"Add more team members with the required role or skills",
|
|
416
|
+
`Reduce coverage target to ${availableEmployees.size}`,
|
|
417
|
+
],
|
|
418
|
+
values: { required: cov.targetCount, available: availableEmployees.size },
|
|
419
|
+
}, bucketStart);
|
|
420
|
+
}
|
|
421
|
+
const coveringVarsSet = new Set();
|
|
422
|
+
for (const pattern of patterns) {
|
|
423
|
+
for (const emp of eligibleEmployees) {
|
|
424
|
+
if (!this.canAssign(emp, pattern))
|
|
425
|
+
continue;
|
|
426
|
+
coveringVarsSet.add(this.assignment(emp.id, pattern.id, cov.day));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const coveringVars = [...coveringVarsSet];
|
|
430
|
+
if (coveringVars.length === 0) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const terms = coveringVars.map((v) => ({ var: v, coeff: 1 }));
|
|
434
|
+
const constraintId = `coverage:${covKey}:${cov.day}:${bucketStart}`;
|
|
435
|
+
if (cov.priority === "MANDATORY") {
|
|
436
|
+
this.addLinear(terms, ">=", cov.targetCount);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
this.addSoftLinear(terms, ">=", cov.targetCount, priorityToPenalty(cov.priority), constraintId);
|
|
440
|
+
}
|
|
441
|
+
// Track all coverage constraints (mandatory and soft) for post-solve reporting
|
|
442
|
+
this.reporter.trackConstraint({
|
|
443
|
+
id: constraintId,
|
|
444
|
+
type: "coverage",
|
|
445
|
+
description: `${cov.targetCount}x ${covKey} on ${cov.day} at ${formatMinutes(bucketStart)}`,
|
|
446
|
+
targetValue: cov.targetCount,
|
|
447
|
+
comparator: ">=",
|
|
448
|
+
day: cov.day,
|
|
449
|
+
timeSlot: formatMinutes(bucketStart),
|
|
450
|
+
roleIds: cov.roleIds,
|
|
451
|
+
skillIds: cov.skillIds,
|
|
452
|
+
context: {
|
|
453
|
+
days: [cov.day],
|
|
454
|
+
employeeIds: eligibleEmployees.map((e) => e.id),
|
|
455
|
+
},
|
|
456
|
+
groupKey: cov.groupKey,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
for (const issue of bucketIssues.values()) {
|
|
460
|
+
const ranges = bucketStartsToRanges(issue.bucketStarts, bucket, covEnd).map((range) => `${formatMinutes(range.start)}-${formatMinutes(range.end)}`);
|
|
461
|
+
if (ranges.length === 0)
|
|
462
|
+
continue;
|
|
463
|
+
const reason = `Coverage for ${coverageLabel} on ${cov.day} (${ranges.join(", ")}) cannot be met: ${issue.reason}.`;
|
|
464
|
+
if (issue.severity === "impossible") {
|
|
465
|
+
this.reporter.reportCoverageError({
|
|
466
|
+
day: cov.day,
|
|
467
|
+
timeSlots: ranges,
|
|
468
|
+
roleIds: cov.roleIds,
|
|
469
|
+
skillIds: cov.skillIds,
|
|
470
|
+
reason,
|
|
471
|
+
suggestions: issue.suggestions,
|
|
472
|
+
groupKey: cov.groupKey,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// Note: soft coverage warnings are tracked via trackConstraint and reported post-solve
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// 3. Default objective: shift minimization with optional fair distribution
|
|
479
|
+
//
|
|
480
|
+
// The objective has three components (see OBJECTIVE_WEIGHTS in utils.ts):
|
|
481
|
+
// a) Minimize number of active shift patterns (SHIFT_ACTIVE=1000)
|
|
482
|
+
// b) Fair distribution (FAIRNESS=5) - minimizes max shifts per person
|
|
483
|
+
// c) Minimize total assignments (ASSIGNMENT_BASE=1) - tiebreaker
|
|
484
|
+
//
|
|
485
|
+
// Weight hierarchy ensures:
|
|
486
|
+
// - SHIFT_ACTIVE >> ASSIGNMENT_PREFERENCE > FAIRNESS > ASSIGNMENT_BASE
|
|
487
|
+
// - Business preferences (±10/shift) override fairness (5 for max)
|
|
488
|
+
// - Fairness overrides pure tiebreaker behavior
|
|
489
|
+
// 3a. Minimize number of active shift patterns (reduces fragmentation)
|
|
490
|
+
for (const pattern of this.shiftPatterns) {
|
|
491
|
+
for (const day of this.days) {
|
|
492
|
+
if (!this.patternAvailableOnDay(pattern, day))
|
|
493
|
+
continue;
|
|
494
|
+
this.addPenalty(this.shiftActive(pattern.id, day), OBJECTIVE_WEIGHTS.SHIFT_ACTIVE);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// 3b. Fair distribution: minimize the maximum shifts any person works
|
|
498
|
+
//
|
|
499
|
+
// When enabled, we use the min-max approach: create an auxiliary variable
|
|
500
|
+
// representing the maximum shifts any person works, constrain each person's
|
|
501
|
+
// total to be <= this max, then minimize it.
|
|
502
|
+
//
|
|
503
|
+
// The FAIRNESS weight (5) is weaker than ASSIGNMENT_PREFERENCE (10), so explicit
|
|
504
|
+
// preferences like "prefer permanent staff over temps" will override fairness.
|
|
505
|
+
if (this.fairDistribution && this.employees.length > 1) {
|
|
506
|
+
const maxPossibleAssignments = this.days.length * this.shiftPatterns.length;
|
|
507
|
+
const maxAssignmentsVar = this.intVar("fairness:max_assignments", 0, maxPossibleAssignments);
|
|
508
|
+
for (const emp of this.employees) {
|
|
509
|
+
const terms = [];
|
|
510
|
+
for (const pattern of this.shiftPatterns) {
|
|
511
|
+
if (!this.canAssign(emp, pattern))
|
|
512
|
+
continue;
|
|
513
|
+
for (const day of this.days) {
|
|
514
|
+
if (!this.patternAvailableOnDay(pattern, day))
|
|
515
|
+
continue;
|
|
516
|
+
terms.push({ var: this.assignment(emp.id, pattern.id, day), coeff: 1 });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (terms.length > 0) {
|
|
520
|
+
// person's total assignments <= maxAssignmentsVar
|
|
521
|
+
terms.push({ var: maxAssignmentsVar, coeff: -1 });
|
|
522
|
+
this.addLinear(terms, "<=", 0);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
this.addPenalty(maxAssignmentsVar, OBJECTIVE_WEIGHTS.FAIRNESS);
|
|
526
|
+
}
|
|
527
|
+
// 3c. Minimize total assignments (tiebreaker)
|
|
528
|
+
for (const emp of this.employees) {
|
|
529
|
+
for (const pattern of this.shiftPatterns) {
|
|
530
|
+
if (!this.canAssign(emp, pattern))
|
|
531
|
+
continue;
|
|
532
|
+
for (const day of this.days) {
|
|
533
|
+
if (!this.patternAvailableOnDay(pattern, day))
|
|
534
|
+
continue;
|
|
535
|
+
this.addPenalty(this.assignment(emp.id, pattern.id, day), OBJECTIVE_WEIGHTS.ASSIGNMENT_BASE);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
this.#builtRequest = this.#buildRequest();
|
|
540
|
+
this.#builtCompilation = {
|
|
541
|
+
request: this.#builtRequest,
|
|
542
|
+
validation: this.reporter.getValidation(),
|
|
543
|
+
canSolve: !this.reporter.hasErrors(),
|
|
544
|
+
};
|
|
545
|
+
return this.#builtCompilation;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Run post-solve validation on all rules.
|
|
549
|
+
* Call this after solving with the resolved assignments.
|
|
550
|
+
*/
|
|
551
|
+
validateSolution(assignments) {
|
|
552
|
+
const context = {
|
|
553
|
+
employees: this.employees,
|
|
554
|
+
days: this.days,
|
|
555
|
+
shiftPatterns: this.shiftPatterns,
|
|
556
|
+
};
|
|
557
|
+
for (const rule of this.rules) {
|
|
558
|
+
rule.validate?.(assignments, this.reporter, context);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
#buildRequest() {
|
|
562
|
+
return {
|
|
563
|
+
variables: Array.from(this.#variables.values()),
|
|
564
|
+
constraints: this.#constraints,
|
|
565
|
+
objective: this.#objective.length > 0 ? { sense: "minimize", terms: this.#objective } : undefined,
|
|
566
|
+
options: this.options,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
#dayOffset(day) {
|
|
570
|
+
const idx = this.#dayIndex.get(day);
|
|
571
|
+
if (idx === undefined) {
|
|
572
|
+
throw new Error(`Unknown day '${day}'`);
|
|
573
|
+
}
|
|
574
|
+
return idx * MINUTES_PER_DAY;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function recordBucketIssue(bucketIssues, issue, bucketStart) {
|
|
578
|
+
const existing = bucketIssues.get(issue.key);
|
|
579
|
+
if (existing) {
|
|
580
|
+
existing.bucketStarts.push(bucketStart);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
bucketIssues.set(issue.key, { ...issue, bucketStarts: [bucketStart] });
|
|
584
|
+
}
|
|
585
|
+
function buildExclusionLookup(exclusions) {
|
|
586
|
+
const lookup = new Map();
|
|
587
|
+
for (const exclusion of exclusions) {
|
|
588
|
+
// If no time specified, exclude entire day
|
|
589
|
+
const startMinutes = exclusion.startTime ? timeOfDayToMinutes(exclusion.startTime) : 0;
|
|
590
|
+
const rawEndMinutes = exclusion.endTime
|
|
591
|
+
? timeOfDayToMinutes(exclusion.endTime)
|
|
592
|
+
: MINUTES_PER_DAY;
|
|
593
|
+
const endMinutes = normalizeEndMinutes(startMinutes, rawEndMinutes);
|
|
594
|
+
const key = `${exclusion.employeeId}:${exclusion.day}`;
|
|
595
|
+
const existing = lookup.get(key);
|
|
596
|
+
const window = { startMinutes, endMinutes };
|
|
597
|
+
if (existing) {
|
|
598
|
+
existing.push(window);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
lookup.set(key, [window]);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return lookup;
|
|
605
|
+
}
|
|
606
|
+
function rangesOverlap(startA, endA, startB, endB) {
|
|
607
|
+
return Math.max(startA, startB) < Math.min(endA, endB);
|
|
608
|
+
}
|
|
609
|
+
function bucketStartsToRanges(bucketStarts, bucketSize, coverageEnd) {
|
|
610
|
+
if (bucketStarts.length === 0)
|
|
611
|
+
return [];
|
|
612
|
+
const sorted = [...bucketStarts].toSorted((a, b) => a - b);
|
|
613
|
+
const ranges = [];
|
|
614
|
+
let currentStart = sorted[0] ?? 0;
|
|
615
|
+
let currentEnd = Math.min(currentStart + bucketSize, coverageEnd);
|
|
616
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
617
|
+
const next = sorted[i];
|
|
618
|
+
if (next === undefined)
|
|
619
|
+
continue;
|
|
620
|
+
if (next <= currentEnd) {
|
|
621
|
+
currentEnd = Math.min(next + bucketSize, coverageEnd);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
ranges.push({ start: currentStart, end: currentEnd });
|
|
625
|
+
currentStart = next;
|
|
626
|
+
currentEnd = Math.min(next + bucketSize, coverageEnd);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
ranges.push({ start: currentStart, end: currentEnd });
|
|
630
|
+
return ranges;
|
|
631
|
+
}
|
|
632
|
+
function formatMinutes(minutes) {
|
|
633
|
+
const hours = Math.floor(minutes / 60);
|
|
634
|
+
const mins = minutes % 60;
|
|
635
|
+
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
|
636
|
+
}
|
|
637
|
+
function formatTimeRange(start, end) {
|
|
638
|
+
return `${formatMinutes(start)}-${formatMinutes(end)}`;
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=model-builder.js.map
|