blackopsrepl commited on
Commit
666f6cf
·
verified ·
1 Parent(s): e7a9b31

Upload 31 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 base image
2
+ FROM python:3.12
3
+
4
+ # Install JDK 21 (required for solverforge-legacy)
5
+ RUN apt-get update && \
6
+ apt-get install -y wget gnupg2 && \
7
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
8
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
9
+ apt-get update && \
10
+ apt-get install -y temurin-21-jdk && \
11
+ apt-get clean && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy application files
15
+ COPY . .
16
+
17
+ # Install the application
18
+ RUN pip install --no-cache-dir -e .
19
+
20
+ # Expose port 8080
21
+ EXPOSE 8080
22
+
23
+ # Run the application
24
+ CMD ["run-app"]
README.md CHANGED
@@ -1,12 +1,89 @@
1
  ---
2
- title: Meeting Scheduling Python
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
  short_description: SolverForge Quickstart for the Meeting Scheduling problem
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Meeting Scheduling (Python)
3
+ emoji: 👀
4
+ colorFrom: gray
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 8080
8
  pinned: false
9
  license: apache-2.0
10
  short_description: SolverForge Quickstart for the Meeting Scheduling problem
11
  ---
12
 
13
+ # Meeting Scheduling (Python)
14
+
15
+ Schedule meetings between employees, where each meeting has a topic, duration, required and preferred attendees.
16
+
17
+ - [Prerequisites](#prerequisites)
18
+ - [Run the application](#run-the-application)
19
+ - [Test the application](#test-the-application)
20
+
21
+ ## Prerequisites
22
+
23
+ 1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
24
+
25
+ 2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
26
+
27
+ ```sh
28
+ $ sdk install java
29
+ ```
30
+
31
+ ## Run the application
32
+
33
+ 1. Git clone the solverforge-quickstarts repo and navigate to this directory:
34
+
35
+ ```sh
36
+ $ git clone https://github.com/SolverForge/solverforge-quickstarts.git
37
+ ...
38
+ $ cd solverforge-quickstarts/fast/meeting-scheduling-fast
39
+ ```
40
+
41
+ 2. Create a virtual environment:
42
+
43
+ ```sh
44
+ $ python -m venv .venv
45
+ ```
46
+
47
+ 3. Activate the virtual environment:
48
+
49
+ ```sh
50
+ $ . .venv/bin/activate
51
+ ```
52
+
53
+ 4. Install the application:
54
+
55
+ ```sh
56
+ $ pip install -e .
57
+ ```
58
+
59
+ 5. Run the application:
60
+
61
+ ```sh
62
+ $ run-app
63
+ ```
64
+
65
+ 6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
66
+
67
+ 7. Click on the **Solve** button.
68
+
69
+ ## Test the application
70
+
71
+ 1. Run tests:
72
+
73
+ ```sh
74
+ $ pytest
75
+ ```
76
+
77
+ ## Problem Description
78
+
79
+ Schedule meetings between employees, where:
80
+
81
+ * Each meeting has a topic, duration, required and preferred attendees.
82
+ * Each meeting needs a room with sufficient capacity.
83
+ * Meetings should not overlap with other meetings if they share resources (room or attendees).
84
+ * Meetings should be scheduled as soon as possible.
85
+ * Preferred attendees should be able to attend if possible.
86
+
87
+ ## More information
88
+
89
+ Visit [solverforge.org](https://www.solverforge.org).
input.json ADDED
@@ -0,0 +1,1978 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "people": [
3
+ {
4
+ "id": "0",
5
+ "fullName": "Flo Green"
6
+ },
7
+ {
8
+ "id": "1",
9
+ "fullName": "Jay Carr"
10
+ },
11
+ {
12
+ "id": "2",
13
+ "fullName": "Amy Smith"
14
+ },
15
+ {
16
+ "id": "3",
17
+ "fullName": "Jay Green"
18
+ },
19
+ {
20
+ "id": "4",
21
+ "fullName": "Avis Carr"
22
+ },
23
+ {
24
+ "id": "5",
25
+ "fullName": "Lino Howe"
26
+ },
27
+ {
28
+ "id": "6",
29
+ "fullName": "Lino Cole"
30
+ },
31
+ {
32
+ "id": "7",
33
+ "fullName": "Hugo Howe"
34
+ },
35
+ {
36
+ "id": "8",
37
+ "fullName": "Russ Wise"
38
+ },
39
+ {
40
+ "id": "9",
41
+ "fullName": "Hugo Jones"
42
+ },
43
+ {
44
+ "id": "10",
45
+ "fullName": "Dino Green"
46
+ },
47
+ {
48
+ "id": "11",
49
+ "fullName": "Lyle Poe"
50
+ },
51
+ {
52
+ "id": "12",
53
+ "fullName": "Nick Li"
54
+ },
55
+ {
56
+ "id": "13",
57
+ "fullName": "Josh Clay"
58
+ },
59
+ {
60
+ "id": "14",
61
+ "fullName": "Josh Smith"
62
+ },
63
+ {
64
+ "id": "15",
65
+ "fullName": "Flo Cole"
66
+ },
67
+ {
68
+ "id": "16",
69
+ "fullName": "Lino Green"
70
+ },
71
+ {
72
+ "id": "17",
73
+ "fullName": "Flo Poe"
74
+ },
75
+ {
76
+ "id": "18",
77
+ "fullName": "Josh Jones"
78
+ },
79
+ {
80
+ "id": "19",
81
+ "fullName": "Beth Horn"
82
+ }
83
+ ],
84
+ "timeGrains": [
85
+ {
86
+ "id": "1",
87
+ "grainIndex": 1,
88
+ "dayOfYear": 175,
89
+ "startingMinuteOfDay": 480
90
+ },
91
+ {
92
+ "id": "2",
93
+ "grainIndex": 2,
94
+ "dayOfYear": 175,
95
+ "startingMinuteOfDay": 495
96
+ },
97
+ {
98
+ "id": "3",
99
+ "grainIndex": 3,
100
+ "dayOfYear": 175,
101
+ "startingMinuteOfDay": 510
102
+ },
103
+ {
104
+ "id": "4",
105
+ "grainIndex": 4,
106
+ "dayOfYear": 175,
107
+ "startingMinuteOfDay": 525
108
+ },
109
+ {
110
+ "id": "5",
111
+ "grainIndex": 5,
112
+ "dayOfYear": 175,
113
+ "startingMinuteOfDay": 540
114
+ },
115
+ {
116
+ "id": "6",
117
+ "grainIndex": 6,
118
+ "dayOfYear": 175,
119
+ "startingMinuteOfDay": 555
120
+ },
121
+ {
122
+ "id": "7",
123
+ "grainIndex": 7,
124
+ "dayOfYear": 175,
125
+ "startingMinuteOfDay": 570
126
+ },
127
+ {
128
+ "id": "8",
129
+ "grainIndex": 8,
130
+ "dayOfYear": 175,
131
+ "startingMinuteOfDay": 585
132
+ },
133
+ {
134
+ "id": "9",
135
+ "grainIndex": 9,
136
+ "dayOfYear": 175,
137
+ "startingMinuteOfDay": 600
138
+ },
139
+ {
140
+ "id": "10",
141
+ "grainIndex": 10,
142
+ "dayOfYear": 175,
143
+ "startingMinuteOfDay": 615
144
+ },
145
+ {
146
+ "id": "11",
147
+ "grainIndex": 11,
148
+ "dayOfYear": 175,
149
+ "startingMinuteOfDay": 630
150
+ },
151
+ {
152
+ "id": "12",
153
+ "grainIndex": 12,
154
+ "dayOfYear": 175,
155
+ "startingMinuteOfDay": 645
156
+ },
157
+ {
158
+ "id": "13",
159
+ "grainIndex": 13,
160
+ "dayOfYear": 175,
161
+ "startingMinuteOfDay": 660
162
+ },
163
+ {
164
+ "id": "14",
165
+ "grainIndex": 14,
166
+ "dayOfYear": 175,
167
+ "startingMinuteOfDay": 675
168
+ },
169
+ {
170
+ "id": "15",
171
+ "grainIndex": 15,
172
+ "dayOfYear": 175,
173
+ "startingMinuteOfDay": 690
174
+ },
175
+ {
176
+ "id": "16",
177
+ "grainIndex": 16,
178
+ "dayOfYear": 175,
179
+ "startingMinuteOfDay": 705
180
+ },
181
+ {
182
+ "id": "17",
183
+ "grainIndex": 17,
184
+ "dayOfYear": 175,
185
+ "startingMinuteOfDay": 720
186
+ },
187
+ {
188
+ "id": "18",
189
+ "grainIndex": 18,
190
+ "dayOfYear": 175,
191
+ "startingMinuteOfDay": 735
192
+ },
193
+ {
194
+ "id": "19",
195
+ "grainIndex": 19,
196
+ "dayOfYear": 175,
197
+ "startingMinuteOfDay": 750
198
+ },
199
+ {
200
+ "id": "20",
201
+ "grainIndex": 20,
202
+ "dayOfYear": 175,
203
+ "startingMinuteOfDay": 765
204
+ },
205
+ {
206
+ "id": "21",
207
+ "grainIndex": 21,
208
+ "dayOfYear": 175,
209
+ "startingMinuteOfDay": 780
210
+ },
211
+ {
212
+ "id": "22",
213
+ "grainIndex": 22,
214
+ "dayOfYear": 175,
215
+ "startingMinuteOfDay": 795
216
+ },
217
+ {
218
+ "id": "23",
219
+ "grainIndex": 23,
220
+ "dayOfYear": 175,
221
+ "startingMinuteOfDay": 810
222
+ },
223
+ {
224
+ "id": "24",
225
+ "grainIndex": 24,
226
+ "dayOfYear": 175,
227
+ "startingMinuteOfDay": 825
228
+ },
229
+ {
230
+ "id": "25",
231
+ "grainIndex": 25,
232
+ "dayOfYear": 175,
233
+ "startingMinuteOfDay": 840
234
+ },
235
+ {
236
+ "id": "26",
237
+ "grainIndex": 26,
238
+ "dayOfYear": 175,
239
+ "startingMinuteOfDay": 855
240
+ },
241
+ {
242
+ "id": "27",
243
+ "grainIndex": 27,
244
+ "dayOfYear": 175,
245
+ "startingMinuteOfDay": 870
246
+ },
247
+ {
248
+ "id": "28",
249
+ "grainIndex": 28,
250
+ "dayOfYear": 175,
251
+ "startingMinuteOfDay": 885
252
+ },
253
+ {
254
+ "id": "29",
255
+ "grainIndex": 29,
256
+ "dayOfYear": 175,
257
+ "startingMinuteOfDay": 900
258
+ },
259
+ {
260
+ "id": "30",
261
+ "grainIndex": 30,
262
+ "dayOfYear": 175,
263
+ "startingMinuteOfDay": 915
264
+ },
265
+ {
266
+ "id": "31",
267
+ "grainIndex": 31,
268
+ "dayOfYear": 175,
269
+ "startingMinuteOfDay": 930
270
+ },
271
+ {
272
+ "id": "32",
273
+ "grainIndex": 32,
274
+ "dayOfYear": 175,
275
+ "startingMinuteOfDay": 945
276
+ },
277
+ {
278
+ "id": "33",
279
+ "grainIndex": 33,
280
+ "dayOfYear": 175,
281
+ "startingMinuteOfDay": 960
282
+ },
283
+ {
284
+ "id": "34",
285
+ "grainIndex": 34,
286
+ "dayOfYear": 175,
287
+ "startingMinuteOfDay": 975
288
+ },
289
+ {
290
+ "id": "35",
291
+ "grainIndex": 35,
292
+ "dayOfYear": 175,
293
+ "startingMinuteOfDay": 990
294
+ },
295
+ {
296
+ "id": "36",
297
+ "grainIndex": 36,
298
+ "dayOfYear": 175,
299
+ "startingMinuteOfDay": 1005
300
+ },
301
+ {
302
+ "id": "37",
303
+ "grainIndex": 37,
304
+ "dayOfYear": 175,
305
+ "startingMinuteOfDay": 1020
306
+ },
307
+ {
308
+ "id": "38",
309
+ "grainIndex": 38,
310
+ "dayOfYear": 175,
311
+ "startingMinuteOfDay": 1035
312
+ },
313
+ {
314
+ "id": "39",
315
+ "grainIndex": 39,
316
+ "dayOfYear": 175,
317
+ "startingMinuteOfDay": 1050
318
+ },
319
+ {
320
+ "id": "40",
321
+ "grainIndex": 40,
322
+ "dayOfYear": 175,
323
+ "startingMinuteOfDay": 1065
324
+ },
325
+ {
326
+ "id": "41",
327
+ "grainIndex": 41,
328
+ "dayOfYear": 176,
329
+ "startingMinuteOfDay": 480
330
+ },
331
+ {
332
+ "id": "42",
333
+ "grainIndex": 42,
334
+ "dayOfYear": 176,
335
+ "startingMinuteOfDay": 495
336
+ },
337
+ {
338
+ "id": "43",
339
+ "grainIndex": 43,
340
+ "dayOfYear": 176,
341
+ "startingMinuteOfDay": 510
342
+ },
343
+ {
344
+ "id": "44",
345
+ "grainIndex": 44,
346
+ "dayOfYear": 176,
347
+ "startingMinuteOfDay": 525
348
+ },
349
+ {
350
+ "id": "45",
351
+ "grainIndex": 45,
352
+ "dayOfYear": 176,
353
+ "startingMinuteOfDay": 540
354
+ },
355
+ {
356
+ "id": "46",
357
+ "grainIndex": 46,
358
+ "dayOfYear": 176,
359
+ "startingMinuteOfDay": 555
360
+ },
361
+ {
362
+ "id": "47",
363
+ "grainIndex": 47,
364
+ "dayOfYear": 176,
365
+ "startingMinuteOfDay": 570
366
+ },
367
+ {
368
+ "id": "48",
369
+ "grainIndex": 48,
370
+ "dayOfYear": 176,
371
+ "startingMinuteOfDay": 585
372
+ },
373
+ {
374
+ "id": "49",
375
+ "grainIndex": 49,
376
+ "dayOfYear": 176,
377
+ "startingMinuteOfDay": 600
378
+ },
379
+ {
380
+ "id": "50",
381
+ "grainIndex": 50,
382
+ "dayOfYear": 176,
383
+ "startingMinuteOfDay": 615
384
+ },
385
+ {
386
+ "id": "51",
387
+ "grainIndex": 51,
388
+ "dayOfYear": 176,
389
+ "startingMinuteOfDay": 630
390
+ },
391
+ {
392
+ "id": "52",
393
+ "grainIndex": 52,
394
+ "dayOfYear": 176,
395
+ "startingMinuteOfDay": 645
396
+ },
397
+ {
398
+ "id": "53",
399
+ "grainIndex": 53,
400
+ "dayOfYear": 176,
401
+ "startingMinuteOfDay": 660
402
+ },
403
+ {
404
+ "id": "54",
405
+ "grainIndex": 54,
406
+ "dayOfYear": 176,
407
+ "startingMinuteOfDay": 675
408
+ },
409
+ {
410
+ "id": "55",
411
+ "grainIndex": 55,
412
+ "dayOfYear": 176,
413
+ "startingMinuteOfDay": 690
414
+ },
415
+ {
416
+ "id": "56",
417
+ "grainIndex": 56,
418
+ "dayOfYear": 176,
419
+ "startingMinuteOfDay": 705
420
+ },
421
+ {
422
+ "id": "57",
423
+ "grainIndex": 57,
424
+ "dayOfYear": 176,
425
+ "startingMinuteOfDay": 720
426
+ },
427
+ {
428
+ "id": "58",
429
+ "grainIndex": 58,
430
+ "dayOfYear": 176,
431
+ "startingMinuteOfDay": 735
432
+ },
433
+ {
434
+ "id": "59",
435
+ "grainIndex": 59,
436
+ "dayOfYear": 176,
437
+ "startingMinuteOfDay": 750
438
+ },
439
+ {
440
+ "id": "60",
441
+ "grainIndex": 60,
442
+ "dayOfYear": 176,
443
+ "startingMinuteOfDay": 765
444
+ },
445
+ {
446
+ "id": "61",
447
+ "grainIndex": 61,
448
+ "dayOfYear": 176,
449
+ "startingMinuteOfDay": 780
450
+ },
451
+ {
452
+ "id": "62",
453
+ "grainIndex": 62,
454
+ "dayOfYear": 176,
455
+ "startingMinuteOfDay": 795
456
+ },
457
+ {
458
+ "id": "63",
459
+ "grainIndex": 63,
460
+ "dayOfYear": 176,
461
+ "startingMinuteOfDay": 810
462
+ },
463
+ {
464
+ "id": "64",
465
+ "grainIndex": 64,
466
+ "dayOfYear": 176,
467
+ "startingMinuteOfDay": 825
468
+ },
469
+ {
470
+ "id": "65",
471
+ "grainIndex": 65,
472
+ "dayOfYear": 176,
473
+ "startingMinuteOfDay": 840
474
+ },
475
+ {
476
+ "id": "66",
477
+ "grainIndex": 66,
478
+ "dayOfYear": 176,
479
+ "startingMinuteOfDay": 855
480
+ },
481
+ {
482
+ "id": "67",
483
+ "grainIndex": 67,
484
+ "dayOfYear": 176,
485
+ "startingMinuteOfDay": 870
486
+ },
487
+ {
488
+ "id": "68",
489
+ "grainIndex": 68,
490
+ "dayOfYear": 176,
491
+ "startingMinuteOfDay": 885
492
+ },
493
+ {
494
+ "id": "69",
495
+ "grainIndex": 69,
496
+ "dayOfYear": 176,
497
+ "startingMinuteOfDay": 900
498
+ },
499
+ {
500
+ "id": "70",
501
+ "grainIndex": 70,
502
+ "dayOfYear": 176,
503
+ "startingMinuteOfDay": 915
504
+ },
505
+ {
506
+ "id": "71",
507
+ "grainIndex": 71,
508
+ "dayOfYear": 176,
509
+ "startingMinuteOfDay": 930
510
+ },
511
+ {
512
+ "id": "72",
513
+ "grainIndex": 72,
514
+ "dayOfYear": 176,
515
+ "startingMinuteOfDay": 945
516
+ },
517
+ {
518
+ "id": "73",
519
+ "grainIndex": 73,
520
+ "dayOfYear": 176,
521
+ "startingMinuteOfDay": 960
522
+ },
523
+ {
524
+ "id": "74",
525
+ "grainIndex": 74,
526
+ "dayOfYear": 176,
527
+ "startingMinuteOfDay": 975
528
+ },
529
+ {
530
+ "id": "75",
531
+ "grainIndex": 75,
532
+ "dayOfYear": 176,
533
+ "startingMinuteOfDay": 990
534
+ },
535
+ {
536
+ "id": "76",
537
+ "grainIndex": 76,
538
+ "dayOfYear": 176,
539
+ "startingMinuteOfDay": 1005
540
+ },
541
+ {
542
+ "id": "77",
543
+ "grainIndex": 77,
544
+ "dayOfYear": 176,
545
+ "startingMinuteOfDay": 1020
546
+ },
547
+ {
548
+ "id": "78",
549
+ "grainIndex": 78,
550
+ "dayOfYear": 176,
551
+ "startingMinuteOfDay": 1035
552
+ },
553
+ {
554
+ "id": "79",
555
+ "grainIndex": 79,
556
+ "dayOfYear": 176,
557
+ "startingMinuteOfDay": 1050
558
+ },
559
+ {
560
+ "id": "80",
561
+ "grainIndex": 80,
562
+ "dayOfYear": 176,
563
+ "startingMinuteOfDay": 1065
564
+ },
565
+ {
566
+ "id": "81",
567
+ "grainIndex": 81,
568
+ "dayOfYear": 177,
569
+ "startingMinuteOfDay": 480
570
+ },
571
+ {
572
+ "id": "82",
573
+ "grainIndex": 82,
574
+ "dayOfYear": 177,
575
+ "startingMinuteOfDay": 495
576
+ },
577
+ {
578
+ "id": "83",
579
+ "grainIndex": 83,
580
+ "dayOfYear": 177,
581
+ "startingMinuteOfDay": 510
582
+ },
583
+ {
584
+ "id": "84",
585
+ "grainIndex": 84,
586
+ "dayOfYear": 177,
587
+ "startingMinuteOfDay": 525
588
+ },
589
+ {
590
+ "id": "85",
591
+ "grainIndex": 85,
592
+ "dayOfYear": 177,
593
+ "startingMinuteOfDay": 540
594
+ },
595
+ {
596
+ "id": "86",
597
+ "grainIndex": 86,
598
+ "dayOfYear": 177,
599
+ "startingMinuteOfDay": 555
600
+ },
601
+ {
602
+ "id": "87",
603
+ "grainIndex": 87,
604
+ "dayOfYear": 177,
605
+ "startingMinuteOfDay": 570
606
+ },
607
+ {
608
+ "id": "88",
609
+ "grainIndex": 88,
610
+ "dayOfYear": 177,
611
+ "startingMinuteOfDay": 585
612
+ },
613
+ {
614
+ "id": "89",
615
+ "grainIndex": 89,
616
+ "dayOfYear": 177,
617
+ "startingMinuteOfDay": 600
618
+ },
619
+ {
620
+ "id": "90",
621
+ "grainIndex": 90,
622
+ "dayOfYear": 177,
623
+ "startingMinuteOfDay": 615
624
+ },
625
+ {
626
+ "id": "91",
627
+ "grainIndex": 91,
628
+ "dayOfYear": 177,
629
+ "startingMinuteOfDay": 630
630
+ },
631
+ {
632
+ "id": "92",
633
+ "grainIndex": 92,
634
+ "dayOfYear": 177,
635
+ "startingMinuteOfDay": 645
636
+ },
637
+ {
638
+ "id": "93",
639
+ "grainIndex": 93,
640
+ "dayOfYear": 177,
641
+ "startingMinuteOfDay": 660
642
+ },
643
+ {
644
+ "id": "94",
645
+ "grainIndex": 94,
646
+ "dayOfYear": 177,
647
+ "startingMinuteOfDay": 675
648
+ },
649
+ {
650
+ "id": "95",
651
+ "grainIndex": 95,
652
+ "dayOfYear": 177,
653
+ "startingMinuteOfDay": 690
654
+ },
655
+ {
656
+ "id": "96",
657
+ "grainIndex": 96,
658
+ "dayOfYear": 177,
659
+ "startingMinuteOfDay": 705
660
+ },
661
+ {
662
+ "id": "97",
663
+ "grainIndex": 97,
664
+ "dayOfYear": 177,
665
+ "startingMinuteOfDay": 720
666
+ },
667
+ {
668
+ "id": "98",
669
+ "grainIndex": 98,
670
+ "dayOfYear": 177,
671
+ "startingMinuteOfDay": 735
672
+ },
673
+ {
674
+ "id": "99",
675
+ "grainIndex": 99,
676
+ "dayOfYear": 177,
677
+ "startingMinuteOfDay": 750
678
+ },
679
+ {
680
+ "id": "100",
681
+ "grainIndex": 100,
682
+ "dayOfYear": 177,
683
+ "startingMinuteOfDay": 765
684
+ },
685
+ {
686
+ "id": "101",
687
+ "grainIndex": 101,
688
+ "dayOfYear": 177,
689
+ "startingMinuteOfDay": 780
690
+ },
691
+ {
692
+ "id": "102",
693
+ "grainIndex": 102,
694
+ "dayOfYear": 177,
695
+ "startingMinuteOfDay": 795
696
+ },
697
+ {
698
+ "id": "103",
699
+ "grainIndex": 103,
700
+ "dayOfYear": 177,
701
+ "startingMinuteOfDay": 810
702
+ },
703
+ {
704
+ "id": "104",
705
+ "grainIndex": 104,
706
+ "dayOfYear": 177,
707
+ "startingMinuteOfDay": 825
708
+ },
709
+ {
710
+ "id": "105",
711
+ "grainIndex": 105,
712
+ "dayOfYear": 177,
713
+ "startingMinuteOfDay": 840
714
+ },
715
+ {
716
+ "id": "106",
717
+ "grainIndex": 106,
718
+ "dayOfYear": 177,
719
+ "startingMinuteOfDay": 855
720
+ },
721
+ {
722
+ "id": "107",
723
+ "grainIndex": 107,
724
+ "dayOfYear": 177,
725
+ "startingMinuteOfDay": 870
726
+ },
727
+ {
728
+ "id": "108",
729
+ "grainIndex": 108,
730
+ "dayOfYear": 177,
731
+ "startingMinuteOfDay": 885
732
+ },
733
+ {
734
+ "id": "109",
735
+ "grainIndex": 109,
736
+ "dayOfYear": 177,
737
+ "startingMinuteOfDay": 900
738
+ },
739
+ {
740
+ "id": "110",
741
+ "grainIndex": 110,
742
+ "dayOfYear": 177,
743
+ "startingMinuteOfDay": 915
744
+ },
745
+ {
746
+ "id": "111",
747
+ "grainIndex": 111,
748
+ "dayOfYear": 177,
749
+ "startingMinuteOfDay": 930
750
+ },
751
+ {
752
+ "id": "112",
753
+ "grainIndex": 112,
754
+ "dayOfYear": 177,
755
+ "startingMinuteOfDay": 945
756
+ },
757
+ {
758
+ "id": "113",
759
+ "grainIndex": 113,
760
+ "dayOfYear": 177,
761
+ "startingMinuteOfDay": 960
762
+ },
763
+ {
764
+ "id": "114",
765
+ "grainIndex": 114,
766
+ "dayOfYear": 177,
767
+ "startingMinuteOfDay": 975
768
+ },
769
+ {
770
+ "id": "115",
771
+ "grainIndex": 115,
772
+ "dayOfYear": 177,
773
+ "startingMinuteOfDay": 990
774
+ },
775
+ {
776
+ "id": "116",
777
+ "grainIndex": 116,
778
+ "dayOfYear": 177,
779
+ "startingMinuteOfDay": 1005
780
+ },
781
+ {
782
+ "id": "117",
783
+ "grainIndex": 117,
784
+ "dayOfYear": 177,
785
+ "startingMinuteOfDay": 1020
786
+ },
787
+ {
788
+ "id": "118",
789
+ "grainIndex": 118,
790
+ "dayOfYear": 177,
791
+ "startingMinuteOfDay": 1035
792
+ },
793
+ {
794
+ "id": "119",
795
+ "grainIndex": 119,
796
+ "dayOfYear": 177,
797
+ "startingMinuteOfDay": 1050
798
+ },
799
+ {
800
+ "id": "120",
801
+ "grainIndex": 120,
802
+ "dayOfYear": 177,
803
+ "startingMinuteOfDay": 1065
804
+ },
805
+ {
806
+ "id": "121",
807
+ "grainIndex": 121,
808
+ "dayOfYear": 178,
809
+ "startingMinuteOfDay": 480
810
+ },
811
+ {
812
+ "id": "122",
813
+ "grainIndex": 122,
814
+ "dayOfYear": 178,
815
+ "startingMinuteOfDay": 495
816
+ },
817
+ {
818
+ "id": "123",
819
+ "grainIndex": 123,
820
+ "dayOfYear": 178,
821
+ "startingMinuteOfDay": 510
822
+ },
823
+ {
824
+ "id": "124",
825
+ "grainIndex": 124,
826
+ "dayOfYear": 178,
827
+ "startingMinuteOfDay": 525
828
+ },
829
+ {
830
+ "id": "125",
831
+ "grainIndex": 125,
832
+ "dayOfYear": 178,
833
+ "startingMinuteOfDay": 540
834
+ },
835
+ {
836
+ "id": "126",
837
+ "grainIndex": 126,
838
+ "dayOfYear": 178,
839
+ "startingMinuteOfDay": 555
840
+ },
841
+ {
842
+ "id": "127",
843
+ "grainIndex": 127,
844
+ "dayOfYear": 178,
845
+ "startingMinuteOfDay": 570
846
+ },
847
+ {
848
+ "id": "128",
849
+ "grainIndex": 128,
850
+ "dayOfYear": 178,
851
+ "startingMinuteOfDay": 585
852
+ },
853
+ {
854
+ "id": "129",
855
+ "grainIndex": 129,
856
+ "dayOfYear": 178,
857
+ "startingMinuteOfDay": 600
858
+ },
859
+ {
860
+ "id": "130",
861
+ "grainIndex": 130,
862
+ "dayOfYear": 178,
863
+ "startingMinuteOfDay": 615
864
+ },
865
+ {
866
+ "id": "131",
867
+ "grainIndex": 131,
868
+ "dayOfYear": 178,
869
+ "startingMinuteOfDay": 630
870
+ },
871
+ {
872
+ "id": "132",
873
+ "grainIndex": 132,
874
+ "dayOfYear": 178,
875
+ "startingMinuteOfDay": 645
876
+ },
877
+ {
878
+ "id": "133",
879
+ "grainIndex": 133,
880
+ "dayOfYear": 178,
881
+ "startingMinuteOfDay": 660
882
+ },
883
+ {
884
+ "id": "134",
885
+ "grainIndex": 134,
886
+ "dayOfYear": 178,
887
+ "startingMinuteOfDay": 675
888
+ },
889
+ {
890
+ "id": "135",
891
+ "grainIndex": 135,
892
+ "dayOfYear": 178,
893
+ "startingMinuteOfDay": 690
894
+ },
895
+ {
896
+ "id": "136",
897
+ "grainIndex": 136,
898
+ "dayOfYear": 178,
899
+ "startingMinuteOfDay": 705
900
+ },
901
+ {
902
+ "id": "137",
903
+ "grainIndex": 137,
904
+ "dayOfYear": 178,
905
+ "startingMinuteOfDay": 720
906
+ },
907
+ {
908
+ "id": "138",
909
+ "grainIndex": 138,
910
+ "dayOfYear": 178,
911
+ "startingMinuteOfDay": 735
912
+ },
913
+ {
914
+ "id": "139",
915
+ "grainIndex": 139,
916
+ "dayOfYear": 178,
917
+ "startingMinuteOfDay": 750
918
+ },
919
+ {
920
+ "id": "140",
921
+ "grainIndex": 140,
922
+ "dayOfYear": 178,
923
+ "startingMinuteOfDay": 765
924
+ },
925
+ {
926
+ "id": "141",
927
+ "grainIndex": 141,
928
+ "dayOfYear": 178,
929
+ "startingMinuteOfDay": 780
930
+ },
931
+ {
932
+ "id": "142",
933
+ "grainIndex": 142,
934
+ "dayOfYear": 178,
935
+ "startingMinuteOfDay": 795
936
+ },
937
+ {
938
+ "id": "143",
939
+ "grainIndex": 143,
940
+ "dayOfYear": 178,
941
+ "startingMinuteOfDay": 810
942
+ },
943
+ {
944
+ "id": "144",
945
+ "grainIndex": 144,
946
+ "dayOfYear": 178,
947
+ "startingMinuteOfDay": 825
948
+ },
949
+ {
950
+ "id": "145",
951
+ "grainIndex": 145,
952
+ "dayOfYear": 178,
953
+ "startingMinuteOfDay": 840
954
+ },
955
+ {
956
+ "id": "146",
957
+ "grainIndex": 146,
958
+ "dayOfYear": 178,
959
+ "startingMinuteOfDay": 855
960
+ },
961
+ {
962
+ "id": "147",
963
+ "grainIndex": 147,
964
+ "dayOfYear": 178,
965
+ "startingMinuteOfDay": 870
966
+ },
967
+ {
968
+ "id": "148",
969
+ "grainIndex": 148,
970
+ "dayOfYear": 178,
971
+ "startingMinuteOfDay": 885
972
+ },
973
+ {
974
+ "id": "149",
975
+ "grainIndex": 149,
976
+ "dayOfYear": 178,
977
+ "startingMinuteOfDay": 900
978
+ },
979
+ {
980
+ "id": "150",
981
+ "grainIndex": 150,
982
+ "dayOfYear": 178,
983
+ "startingMinuteOfDay": 915
984
+ },
985
+ {
986
+ "id": "151",
987
+ "grainIndex": 151,
988
+ "dayOfYear": 178,
989
+ "startingMinuteOfDay": 930
990
+ },
991
+ {
992
+ "id": "152",
993
+ "grainIndex": 152,
994
+ "dayOfYear": 178,
995
+ "startingMinuteOfDay": 945
996
+ },
997
+ {
998
+ "id": "153",
999
+ "grainIndex": 153,
1000
+ "dayOfYear": 178,
1001
+ "startingMinuteOfDay": 960
1002
+ },
1003
+ {
1004
+ "id": "154",
1005
+ "grainIndex": 154,
1006
+ "dayOfYear": 178,
1007
+ "startingMinuteOfDay": 975
1008
+ },
1009
+ {
1010
+ "id": "155",
1011
+ "grainIndex": 155,
1012
+ "dayOfYear": 178,
1013
+ "startingMinuteOfDay": 990
1014
+ },
1015
+ {
1016
+ "id": "156",
1017
+ "grainIndex": 156,
1018
+ "dayOfYear": 178,
1019
+ "startingMinuteOfDay": 1005
1020
+ },
1021
+ {
1022
+ "id": "157",
1023
+ "grainIndex": 157,
1024
+ "dayOfYear": 178,
1025
+ "startingMinuteOfDay": 1020
1026
+ },
1027
+ {
1028
+ "id": "158",
1029
+ "grainIndex": 158,
1030
+ "dayOfYear": 178,
1031
+ "startingMinuteOfDay": 1035
1032
+ },
1033
+ {
1034
+ "id": "159",
1035
+ "grainIndex": 159,
1036
+ "dayOfYear": 178,
1037
+ "startingMinuteOfDay": 1050
1038
+ },
1039
+ {
1040
+ "id": "160",
1041
+ "grainIndex": 160,
1042
+ "dayOfYear": 178,
1043
+ "startingMinuteOfDay": 1065
1044
+ }
1045
+ ],
1046
+ "rooms": [
1047
+ {
1048
+ "id": "R1",
1049
+ "name": "Room 1",
1050
+ "capacity": 30
1051
+ },
1052
+ {
1053
+ "id": "R2",
1054
+ "name": "Room 2",
1055
+ "capacity": 20
1056
+ },
1057
+ {
1058
+ "id": "R3",
1059
+ "name": "Room 3",
1060
+ "capacity": 16
1061
+ }
1062
+ ],
1063
+ "meetings": [
1064
+ {
1065
+ "id": "0",
1066
+ "topic": "Strategize B2B",
1067
+ "speakers": [],
1068
+ "content": "",
1069
+ "entireGroupMeeting": false,
1070
+ "durationInGrains": 8,
1071
+ "requiredAttendances": [
1072
+ {
1073
+ "id": "0-1",
1074
+ "person": "14",
1075
+ "meeting": "0"
1076
+ },
1077
+ {
1078
+ "id": "0-2",
1079
+ "person": "19",
1080
+ "meeting": "0"
1081
+ }
1082
+ ],
1083
+ "preferredAttendances": []
1084
+ },
1085
+ {
1086
+ "id": "1",
1087
+ "topic": "Fast track e-business",
1088
+ "speakers": [],
1089
+ "content": "",
1090
+ "entireGroupMeeting": false,
1091
+ "durationInGrains": 16,
1092
+ "requiredAttendances": [
1093
+ {
1094
+ "id": "1-1",
1095
+ "person": "15",
1096
+ "meeting": "1"
1097
+ },
1098
+ {
1099
+ "id": "1-2",
1100
+ "person": "17",
1101
+ "meeting": "1"
1102
+ },
1103
+ {
1104
+ "id": "1-3",
1105
+ "person": "16",
1106
+ "meeting": "1"
1107
+ },
1108
+ {
1109
+ "id": "1-4",
1110
+ "person": "6",
1111
+ "meeting": "1"
1112
+ },
1113
+ {
1114
+ "id": "1-5",
1115
+ "person": "4",
1116
+ "meeting": "1"
1117
+ },
1118
+ {
1119
+ "id": "1-6",
1120
+ "person": "11",
1121
+ "meeting": "1"
1122
+ },
1123
+ {
1124
+ "id": "1-7",
1125
+ "person": "10",
1126
+ "meeting": "1"
1127
+ }
1128
+ ],
1129
+ "preferredAttendances": [
1130
+ {
1131
+ "id": "1-8",
1132
+ "person": "3",
1133
+ "meeting": "1"
1134
+ },
1135
+ {
1136
+ "id": "1-9",
1137
+ "person": "8",
1138
+ "meeting": "1"
1139
+ },
1140
+ {
1141
+ "id": "1-10",
1142
+ "person": "1",
1143
+ "meeting": "1"
1144
+ }
1145
+ ]
1146
+ },
1147
+ {
1148
+ "id": "2",
1149
+ "topic": "Cross sell virtualization",
1150
+ "speakers": [],
1151
+ "content": "",
1152
+ "entireGroupMeeting": false,
1153
+ "durationInGrains": 8,
1154
+ "requiredAttendances": [
1155
+ {
1156
+ "id": "2-1",
1157
+ "person": "3",
1158
+ "meeting": "2"
1159
+ },
1160
+ {
1161
+ "id": "2-2",
1162
+ "person": "2",
1163
+ "meeting": "2"
1164
+ },
1165
+ {
1166
+ "id": "2-3",
1167
+ "person": "15",
1168
+ "meeting": "2"
1169
+ },
1170
+ {
1171
+ "id": "2-4",
1172
+ "person": "8",
1173
+ "meeting": "2"
1174
+ },
1175
+ {
1176
+ "id": "2-5",
1177
+ "person": "1",
1178
+ "meeting": "2"
1179
+ }
1180
+ ],
1181
+ "preferredAttendances": []
1182
+ },
1183
+ {
1184
+ "id": "3",
1185
+ "topic": "Profitize multitasking",
1186
+ "speakers": [],
1187
+ "content": "",
1188
+ "entireGroupMeeting": false,
1189
+ "durationInGrains": 16,
1190
+ "requiredAttendances": [
1191
+ {
1192
+ "id": "3-1",
1193
+ "person": "0",
1194
+ "meeting": "3"
1195
+ },
1196
+ {
1197
+ "id": "3-2",
1198
+ "person": "18",
1199
+ "meeting": "3"
1200
+ }
1201
+ ],
1202
+ "preferredAttendances": [
1203
+ {
1204
+ "id": "3-3",
1205
+ "person": "12",
1206
+ "meeting": "3"
1207
+ },
1208
+ {
1209
+ "id": "3-4",
1210
+ "person": "19",
1211
+ "meeting": "3"
1212
+ }
1213
+ ]
1214
+ },
1215
+ {
1216
+ "id": "4",
1217
+ "topic": "Transform one stop shop",
1218
+ "speakers": [],
1219
+ "content": "",
1220
+ "entireGroupMeeting": false,
1221
+ "durationInGrains": 12,
1222
+ "requiredAttendances": [
1223
+ {
1224
+ "id": "4-1",
1225
+ "person": "19",
1226
+ "meeting": "4"
1227
+ },
1228
+ {
1229
+ "id": "4-2",
1230
+ "person": "18",
1231
+ "meeting": "4"
1232
+ },
1233
+ {
1234
+ "id": "4-3",
1235
+ "person": "11",
1236
+ "meeting": "4"
1237
+ },
1238
+ {
1239
+ "id": "4-4",
1240
+ "person": "0",
1241
+ "meeting": "4"
1242
+ },
1243
+ {
1244
+ "id": "4-5",
1245
+ "person": "9",
1246
+ "meeting": "4"
1247
+ },
1248
+ {
1249
+ "id": "4-6",
1250
+ "person": "17",
1251
+ "meeting": "4"
1252
+ }
1253
+ ],
1254
+ "preferredAttendances": []
1255
+ },
1256
+ {
1257
+ "id": "5",
1258
+ "topic": "Engage braindumps",
1259
+ "speakers": [],
1260
+ "content": "",
1261
+ "entireGroupMeeting": false,
1262
+ "durationInGrains": 12,
1263
+ "requiredAttendances": [],
1264
+ "preferredAttendances": []
1265
+ },
1266
+ {
1267
+ "id": "6",
1268
+ "topic": "Downsize data mining",
1269
+ "speakers": [],
1270
+ "content": "",
1271
+ "entireGroupMeeting": false,
1272
+ "durationInGrains": 8,
1273
+ "requiredAttendances": [
1274
+ {
1275
+ "id": "6-1",
1276
+ "person": "15",
1277
+ "meeting": "6"
1278
+ },
1279
+ {
1280
+ "id": "6-2",
1281
+ "person": "4",
1282
+ "meeting": "6"
1283
+ }
1284
+ ],
1285
+ "preferredAttendances": [
1286
+ {
1287
+ "id": "6-3",
1288
+ "person": "6",
1289
+ "meeting": "6"
1290
+ },
1291
+ {
1292
+ "id": "6-4",
1293
+ "person": "14",
1294
+ "meeting": "6"
1295
+ },
1296
+ {
1297
+ "id": "6-5",
1298
+ "person": "16",
1299
+ "meeting": "6"
1300
+ }
1301
+ ]
1302
+ },
1303
+ {
1304
+ "id": "7",
1305
+ "topic": "Ramp up policies",
1306
+ "speakers": [],
1307
+ "content": "",
1308
+ "entireGroupMeeting": false,
1309
+ "durationInGrains": 16,
1310
+ "requiredAttendances": [
1311
+ {
1312
+ "id": "7-1",
1313
+ "person": "0",
1314
+ "meeting": "7"
1315
+ },
1316
+ {
1317
+ "id": "7-2",
1318
+ "person": "14",
1319
+ "meeting": "7"
1320
+ },
1321
+ {
1322
+ "id": "7-3",
1323
+ "person": "7",
1324
+ "meeting": "7"
1325
+ },
1326
+ {
1327
+ "id": "7-4",
1328
+ "person": "3",
1329
+ "meeting": "7"
1330
+ },
1331
+ {
1332
+ "id": "7-5",
1333
+ "person": "6",
1334
+ "meeting": "7"
1335
+ },
1336
+ {
1337
+ "id": "7-6",
1338
+ "person": "12",
1339
+ "meeting": "7"
1340
+ },
1341
+ {
1342
+ "id": "7-7",
1343
+ "person": "4",
1344
+ "meeting": "7"
1345
+ },
1346
+ {
1347
+ "id": "7-8",
1348
+ "person": "18",
1349
+ "meeting": "7"
1350
+ }
1351
+ ],
1352
+ "preferredAttendances": []
1353
+ },
1354
+ {
1355
+ "id": "8",
1356
+ "topic": "On board synergies",
1357
+ "speakers": [],
1358
+ "content": "",
1359
+ "entireGroupMeeting": false,
1360
+ "durationInGrains": 12,
1361
+ "requiredAttendances": [],
1362
+ "preferredAttendances": [
1363
+ {
1364
+ "id": "8-1",
1365
+ "person": "1",
1366
+ "meeting": "8"
1367
+ },
1368
+ {
1369
+ "id": "8-2",
1370
+ "person": "2",
1371
+ "meeting": "8"
1372
+ },
1373
+ {
1374
+ "id": "8-3",
1375
+ "person": "6",
1376
+ "meeting": "8"
1377
+ },
1378
+ {
1379
+ "id": "8-4",
1380
+ "person": "18",
1381
+ "meeting": "8"
1382
+ }
1383
+ ]
1384
+ },
1385
+ {
1386
+ "id": "9",
1387
+ "topic": "Reinvigorate user experience",
1388
+ "speakers": [],
1389
+ "content": "",
1390
+ "entireGroupMeeting": false,
1391
+ "durationInGrains": 16,
1392
+ "requiredAttendances": [],
1393
+ "preferredAttendances": [
1394
+ {
1395
+ "id": "9-1",
1396
+ "person": "14",
1397
+ "meeting": "9"
1398
+ },
1399
+ {
1400
+ "id": "9-2",
1401
+ "person": "9",
1402
+ "meeting": "9"
1403
+ },
1404
+ {
1405
+ "id": "9-3",
1406
+ "person": "18",
1407
+ "meeting": "9"
1408
+ }
1409
+ ]
1410
+ },
1411
+ {
1412
+ "id": "10",
1413
+ "topic": "Strategize e-business",
1414
+ "speakers": [],
1415
+ "content": "",
1416
+ "entireGroupMeeting": false,
1417
+ "durationInGrains": 16,
1418
+ "requiredAttendances": [],
1419
+ "preferredAttendances": [
1420
+ {
1421
+ "id": "10-1",
1422
+ "person": "17",
1423
+ "meeting": "10"
1424
+ },
1425
+ {
1426
+ "id": "10-2",
1427
+ "person": "13",
1428
+ "meeting": "10"
1429
+ }
1430
+ ]
1431
+ },
1432
+ {
1433
+ "id": "11",
1434
+ "topic": "Fast track virtualization",
1435
+ "speakers": [],
1436
+ "content": "",
1437
+ "entireGroupMeeting": false,
1438
+ "durationInGrains": 12,
1439
+ "requiredAttendances": [],
1440
+ "preferredAttendances": [
1441
+ {
1442
+ "id": "11-1",
1443
+ "person": "15",
1444
+ "meeting": "11"
1445
+ },
1446
+ {
1447
+ "id": "11-2",
1448
+ "person": "6",
1449
+ "meeting": "11"
1450
+ },
1451
+ {
1452
+ "id": "11-3",
1453
+ "person": "2",
1454
+ "meeting": "11"
1455
+ },
1456
+ {
1457
+ "id": "11-4",
1458
+ "person": "9",
1459
+ "meeting": "11"
1460
+ },
1461
+ {
1462
+ "id": "11-5",
1463
+ "person": "7",
1464
+ "meeting": "11"
1465
+ },
1466
+ {
1467
+ "id": "11-6",
1468
+ "person": "8",
1469
+ "meeting": "11"
1470
+ },
1471
+ {
1472
+ "id": "11-7",
1473
+ "person": "11",
1474
+ "meeting": "11"
1475
+ },
1476
+ {
1477
+ "id": "11-8",
1478
+ "person": "0",
1479
+ "meeting": "11"
1480
+ },
1481
+ {
1482
+ "id": "11-9",
1483
+ "person": "13",
1484
+ "meeting": "11"
1485
+ }
1486
+ ]
1487
+ },
1488
+ {
1489
+ "id": "12",
1490
+ "topic": "Cross sell multitasking",
1491
+ "speakers": [],
1492
+ "content": "",
1493
+ "entireGroupMeeting": false,
1494
+ "durationInGrains": 16,
1495
+ "requiredAttendances": [
1496
+ {
1497
+ "id": "12-1",
1498
+ "person": "12",
1499
+ "meeting": "12"
1500
+ },
1501
+ {
1502
+ "id": "12-2",
1503
+ "person": "11",
1504
+ "meeting": "12"
1505
+ }
1506
+ ],
1507
+ "preferredAttendances": [
1508
+ {
1509
+ "id": "12-3",
1510
+ "person": "9",
1511
+ "meeting": "12"
1512
+ },
1513
+ {
1514
+ "id": "12-4",
1515
+ "person": "2",
1516
+ "meeting": "12"
1517
+ }
1518
+ ]
1519
+ },
1520
+ {
1521
+ "id": "13",
1522
+ "topic": "Profitize one stop shop",
1523
+ "speakers": [],
1524
+ "content": "",
1525
+ "entireGroupMeeting": false,
1526
+ "durationInGrains": 12,
1527
+ "requiredAttendances": [],
1528
+ "preferredAttendances": [
1529
+ {
1530
+ "id": "13-1",
1531
+ "person": "16",
1532
+ "meeting": "13"
1533
+ },
1534
+ {
1535
+ "id": "13-2",
1536
+ "person": "0",
1537
+ "meeting": "13"
1538
+ }
1539
+ ]
1540
+ },
1541
+ {
1542
+ "id": "14",
1543
+ "topic": "Transform braindumps",
1544
+ "speakers": [],
1545
+ "content": "",
1546
+ "entireGroupMeeting": false,
1547
+ "durationInGrains": 16,
1548
+ "requiredAttendances": [
1549
+ {
1550
+ "id": "14-1",
1551
+ "person": "4",
1552
+ "meeting": "14"
1553
+ },
1554
+ {
1555
+ "id": "14-2",
1556
+ "person": "17",
1557
+ "meeting": "14"
1558
+ }
1559
+ ],
1560
+ "preferredAttendances": []
1561
+ },
1562
+ {
1563
+ "id": "15",
1564
+ "topic": "Engage data mining",
1565
+ "speakers": [],
1566
+ "content": "",
1567
+ "entireGroupMeeting": false,
1568
+ "durationInGrains": 8,
1569
+ "requiredAttendances": [
1570
+ {
1571
+ "id": "15-1",
1572
+ "person": "6",
1573
+ "meeting": "15"
1574
+ },
1575
+ {
1576
+ "id": "15-2",
1577
+ "person": "18",
1578
+ "meeting": "15"
1579
+ }
1580
+ ],
1581
+ "preferredAttendances": [
1582
+ {
1583
+ "id": "15-3",
1584
+ "person": "7",
1585
+ "meeting": "15"
1586
+ }
1587
+ ]
1588
+ },
1589
+ {
1590
+ "id": "16",
1591
+ "topic": "Downsize policies",
1592
+ "speakers": [],
1593
+ "content": "",
1594
+ "entireGroupMeeting": false,
1595
+ "durationInGrains": 8,
1596
+ "requiredAttendances": [],
1597
+ "preferredAttendances": []
1598
+ },
1599
+ {
1600
+ "id": "17",
1601
+ "topic": "Ramp up synergies",
1602
+ "speakers": [],
1603
+ "content": "",
1604
+ "entireGroupMeeting": false,
1605
+ "durationInGrains": 8,
1606
+ "requiredAttendances": [
1607
+ {
1608
+ "id": "17-1",
1609
+ "person": "6",
1610
+ "meeting": "17"
1611
+ },
1612
+ {
1613
+ "id": "17-2",
1614
+ "person": "11",
1615
+ "meeting": "17"
1616
+ }
1617
+ ],
1618
+ "preferredAttendances": []
1619
+ },
1620
+ {
1621
+ "id": "18",
1622
+ "topic": "On board user experience",
1623
+ "speakers": [],
1624
+ "content": "",
1625
+ "entireGroupMeeting": false,
1626
+ "durationInGrains": 12,
1627
+ "requiredAttendances": [
1628
+ {
1629
+ "id": "18-1",
1630
+ "person": "11",
1631
+ "meeting": "18"
1632
+ },
1633
+ {
1634
+ "id": "18-2",
1635
+ "person": "6",
1636
+ "meeting": "18"
1637
+ }
1638
+ ],
1639
+ "preferredAttendances": []
1640
+ },
1641
+ {
1642
+ "id": "19",
1643
+ "topic": "Reinvigorate B2B",
1644
+ "speakers": [],
1645
+ "content": "",
1646
+ "entireGroupMeeting": false,
1647
+ "durationInGrains": 8,
1648
+ "requiredAttendances": [],
1649
+ "preferredAttendances": [
1650
+ {
1651
+ "id": "19-1",
1652
+ "person": "12",
1653
+ "meeting": "19"
1654
+ },
1655
+ {
1656
+ "id": "19-2",
1657
+ "person": "16",
1658
+ "meeting": "19"
1659
+ },
1660
+ {
1661
+ "id": "19-3",
1662
+ "person": "18",
1663
+ "meeting": "19"
1664
+ }
1665
+ ]
1666
+ },
1667
+ {
1668
+ "id": "20",
1669
+ "topic": "Strategize virtualization",
1670
+ "speakers": [],
1671
+ "content": "",
1672
+ "entireGroupMeeting": false,
1673
+ "durationInGrains": 8,
1674
+ "requiredAttendances": [],
1675
+ "preferredAttendances": []
1676
+ },
1677
+ {
1678
+ "id": "21",
1679
+ "topic": "Fast track multitasking",
1680
+ "speakers": [],
1681
+ "content": "",
1682
+ "entireGroupMeeting": false,
1683
+ "durationInGrains": 12,
1684
+ "requiredAttendances": [
1685
+ {
1686
+ "id": "21-1",
1687
+ "person": "5",
1688
+ "meeting": "21"
1689
+ },
1690
+ {
1691
+ "id": "21-2",
1692
+ "person": "4",
1693
+ "meeting": "21"
1694
+ },
1695
+ {
1696
+ "id": "21-3",
1697
+ "person": "17",
1698
+ "meeting": "21"
1699
+ }
1700
+ ],
1701
+ "preferredAttendances": []
1702
+ },
1703
+ {
1704
+ "id": "22",
1705
+ "topic": "Cross sell one stop shop",
1706
+ "speakers": [],
1707
+ "content": "",
1708
+ "entireGroupMeeting": false,
1709
+ "durationInGrains": 8,
1710
+ "requiredAttendances": [
1711
+ {
1712
+ "id": "22-1",
1713
+ "person": "3",
1714
+ "meeting": "22"
1715
+ },
1716
+ {
1717
+ "id": "22-2",
1718
+ "person": "8",
1719
+ "meeting": "22"
1720
+ },
1721
+ {
1722
+ "id": "22-3",
1723
+ "person": "1",
1724
+ "meeting": "22"
1725
+ },
1726
+ {
1727
+ "id": "22-4",
1728
+ "person": "0",
1729
+ "meeting": "22"
1730
+ },
1731
+ {
1732
+ "id": "22-5",
1733
+ "person": "6",
1734
+ "meeting": "22"
1735
+ },
1736
+ {
1737
+ "id": "22-6",
1738
+ "person": "14",
1739
+ "meeting": "22"
1740
+ },
1741
+ {
1742
+ "id": "22-7",
1743
+ "person": "13",
1744
+ "meeting": "22"
1745
+ },
1746
+ {
1747
+ "id": "22-8",
1748
+ "person": "2",
1749
+ "meeting": "22"
1750
+ },
1751
+ {
1752
+ "id": "22-9",
1753
+ "person": "16",
1754
+ "meeting": "22"
1755
+ },
1756
+ {
1757
+ "id": "22-10",
1758
+ "person": "18",
1759
+ "meeting": "22"
1760
+ }
1761
+ ],
1762
+ "preferredAttendances": []
1763
+ },
1764
+ {
1765
+ "id": "23",
1766
+ "topic": "Reinvigorate multitasking",
1767
+ "speakers": [],
1768
+ "content": "",
1769
+ "entireGroupMeeting": false,
1770
+ "durationInGrains": 8,
1771
+ "requiredAttendances": [
1772
+ {
1773
+ "id": "23-1",
1774
+ "person": "16",
1775
+ "meeting": "23"
1776
+ },
1777
+ {
1778
+ "id": "23-2",
1779
+ "person": "12",
1780
+ "meeting": "23"
1781
+ },
1782
+ {
1783
+ "id": "23-3",
1784
+ "person": "3",
1785
+ "meeting": "23"
1786
+ },
1787
+ {
1788
+ "id": "23-4",
1789
+ "person": "13",
1790
+ "meeting": "23"
1791
+ },
1792
+ {
1793
+ "id": "23-5",
1794
+ "person": "18",
1795
+ "meeting": "23"
1796
+ },
1797
+ {
1798
+ "id": "23-6",
1799
+ "person": "10",
1800
+ "meeting": "23"
1801
+ }
1802
+ ],
1803
+ "preferredAttendances": []
1804
+ }
1805
+ ],
1806
+ "meetingAssignments": [
1807
+ {
1808
+ "id": "0",
1809
+ "meeting": "0",
1810
+ "pinned": false,
1811
+ "startingTimeGrain": null,
1812
+ "room": null
1813
+ },
1814
+ {
1815
+ "id": "1",
1816
+ "meeting": "1",
1817
+ "pinned": false,
1818
+ "startingTimeGrain": null,
1819
+ "room": null
1820
+ },
1821
+ {
1822
+ "id": "2",
1823
+ "meeting": "2",
1824
+ "pinned": false,
1825
+ "startingTimeGrain": null,
1826
+ "room": null
1827
+ },
1828
+ {
1829
+ "id": "3",
1830
+ "meeting": "3",
1831
+ "pinned": false,
1832
+ "startingTimeGrain": null,
1833
+ "room": null
1834
+ },
1835
+ {
1836
+ "id": "4",
1837
+ "meeting": "4",
1838
+ "pinned": false,
1839
+ "startingTimeGrain": null,
1840
+ "room": null
1841
+ },
1842
+ {
1843
+ "id": "5",
1844
+ "meeting": "5",
1845
+ "pinned": false,
1846
+ "startingTimeGrain": null,
1847
+ "room": null
1848
+ },
1849
+ {
1850
+ "id": "6",
1851
+ "meeting": "6",
1852
+ "pinned": false,
1853
+ "startingTimeGrain": null,
1854
+ "room": null
1855
+ },
1856
+ {
1857
+ "id": "7",
1858
+ "meeting": "7",
1859
+ "pinned": false,
1860
+ "startingTimeGrain": null,
1861
+ "room": null
1862
+ },
1863
+ {
1864
+ "id": "8",
1865
+ "meeting": "8",
1866
+ "pinned": false,
1867
+ "startingTimeGrain": null,
1868
+ "room": null
1869
+ },
1870
+ {
1871
+ "id": "9",
1872
+ "meeting": "9",
1873
+ "pinned": false,
1874
+ "startingTimeGrain": null,
1875
+ "room": null
1876
+ },
1877
+ {
1878
+ "id": "10",
1879
+ "meeting": "10",
1880
+ "pinned": false,
1881
+ "startingTimeGrain": null,
1882
+ "room": null
1883
+ },
1884
+ {
1885
+ "id": "11",
1886
+ "meeting": "11",
1887
+ "pinned": false,
1888
+ "startingTimeGrain": null,
1889
+ "room": null
1890
+ },
1891
+ {
1892
+ "id": "12",
1893
+ "meeting": "12",
1894
+ "pinned": false,
1895
+ "startingTimeGrain": null,
1896
+ "room": null
1897
+ },
1898
+ {
1899
+ "id": "13",
1900
+ "meeting": "13",
1901
+ "pinned": false,
1902
+ "startingTimeGrain": null,
1903
+ "room": null
1904
+ },
1905
+ {
1906
+ "id": "14",
1907
+ "meeting": "14",
1908
+ "pinned": false,
1909
+ "startingTimeGrain": null,
1910
+ "room": null
1911
+ },
1912
+ {
1913
+ "id": "15",
1914
+ "meeting": "15",
1915
+ "pinned": false,
1916
+ "startingTimeGrain": null,
1917
+ "room": null
1918
+ },
1919
+ {
1920
+ "id": "16",
1921
+ "meeting": "16",
1922
+ "pinned": false,
1923
+ "startingTimeGrain": null,
1924
+ "room": null
1925
+ },
1926
+ {
1927
+ "id": "17",
1928
+ "meeting": "17",
1929
+ "pinned": false,
1930
+ "startingTimeGrain": null,
1931
+ "room": null
1932
+ },
1933
+ {
1934
+ "id": "18",
1935
+ "meeting": "18",
1936
+ "pinned": false,
1937
+ "startingTimeGrain": null,
1938
+ "room": null
1939
+ },
1940
+ {
1941
+ "id": "19",
1942
+ "meeting": "19",
1943
+ "pinned": false,
1944
+ "startingTimeGrain": null,
1945
+ "room": null
1946
+ },
1947
+ {
1948
+ "id": "20",
1949
+ "meeting": "20",
1950
+ "pinned": false,
1951
+ "startingTimeGrain": null,
1952
+ "room": null
1953
+ },
1954
+ {
1955
+ "id": "21",
1956
+ "meeting": "21",
1957
+ "pinned": false,
1958
+ "startingTimeGrain": null,
1959
+ "room": null
1960
+ },
1961
+ {
1962
+ "id": "22",
1963
+ "meeting": "22",
1964
+ "pinned": false,
1965
+ "startingTimeGrain": null,
1966
+ "room": null
1967
+ },
1968
+ {
1969
+ "id": "23",
1970
+ "meeting": "23",
1971
+ "pinned": false,
1972
+ "startingTimeGrain": null,
1973
+ "room": null
1974
+ }
1975
+ ],
1976
+ "score": null,
1977
+ "solverStatus": null
1978
+ }
logging.conf ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [loggers]
2
+ keys=root,timefold
3
+
4
+ [handlers]
5
+ keys=consoleHandler
6
+
7
+ [formatters]
8
+ keys=simpleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler
13
+
14
+ [logger_timefold]
15
+ level=INFO
16
+ handlers=consoleHandler
17
+ qualname=timefold
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=INFO
23
+ formatter=simpleFormatter
24
+ args=(sys.stdout,)
25
+
26
+ [formatter_simpleFormatter]
27
+ format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
28
+ datefmt=%Y-%m-%d %H:%M:%S
pyproject.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "meeting_scheduling"
7
+ version = "1.0.0"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ 'solverforge-legacy == 1.24.1',
11
+ 'fastapi == 0.111.0',
12
+ 'pydantic == 2.7.3',
13
+ 'uvicorn == 0.30.1',
14
+ 'pytest == 8.2.2',
15
+ 'httpx == 0.27.0',
16
+ ]
17
+
18
+ [project.scripts]
19
+ run-app = "meeting_scheduling:main"
src/meeting_scheduling/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+ from .rest_api import app as app
3
+
4
+ def main():
5
+ config = uvicorn.Config("meeting_scheduling:app",
6
+ port=8080,
7
+ log_config="logging.conf",
8
+ use_colors=True)
9
+ server = uvicorn.Server(config)
10
+ server.run()
11
+
12
+ if __name__ == "__main__":
13
+ main()
src/meeting_scheduling/constraints.py ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import (
2
+ constraint_provider,
3
+ HardMediumSoftScore,
4
+ Joiners,
5
+ ConstraintFactory,
6
+ Constraint,
7
+ )
8
+
9
+ from .domain import (
10
+ Attendance,
11
+ MeetingAssignment,
12
+ PreferredAttendance,
13
+ RequiredAttendance,
14
+ Room,
15
+ TimeGrain,
16
+ )
17
+
18
+
19
+ @constraint_provider
20
+ def define_constraints(constraint_factory: ConstraintFactory):
21
+ """
22
+ Defines all constraints for the meeting scheduling problem, organized by priority (hard, medium, soft).
23
+
24
+ Args:
25
+ constraint_factory (ConstraintFactory): The constraint factory.
26
+ Returns:
27
+ List[Constraint]: All defined constraints.
28
+ """
29
+ return [
30
+ # Hard constraints
31
+ room_conflict(constraint_factory),
32
+ avoid_overtime(constraint_factory),
33
+ required_attendance_conflict(constraint_factory),
34
+ required_room_capacity(constraint_factory),
35
+ start_and_end_on_same_day(constraint_factory),
36
+ # Medium constraints
37
+ required_and_preferred_attendance_conflict(constraint_factory),
38
+ preferred_attendance_conflict(constraint_factory),
39
+ # Soft constraints
40
+ do_meetings_as_soon_as_possible(constraint_factory),
41
+ one_break_between_consecutive_meetings(constraint_factory),
42
+ overlapping_meetings(constraint_factory),
43
+ assign_larger_rooms_first(constraint_factory),
44
+ room_stability(constraint_factory),
45
+ ]
46
+
47
+
48
+ # ************************************************************************
49
+ # Hard constraints
50
+ # ************************************************************************
51
+
52
+
53
+ def room_conflict(constraint_factory: ConstraintFactory) -> Constraint:
54
+ """
55
+ Hard constraint: Prevents overlapping meetings in the same room.
56
+
57
+ Penalizes pairs of meetings scheduled in the same room whose time slots overlap, with penalty proportional to the overlap duration.
58
+
59
+ Args:
60
+ constraint_factory (ConstraintFactory): The constraint factory.
61
+ Returns:
62
+ Constraint: The defined constraint.
63
+ """
64
+ return (
65
+ constraint_factory.for_each_unique_pair(
66
+ MeetingAssignment,
67
+ Joiners.equal(lambda assignment: assignment.room),
68
+ Joiners.overlapping(
69
+ lambda assignment: assignment.get_grain_index(),
70
+ lambda assignment: assignment.get_last_time_grain_index() + 1,
71
+ ),
72
+ )
73
+ .penalize(
74
+ HardMediumSoftScore.ONE_HARD,
75
+ lambda left_assignment,
76
+ right_assignment: right_assignment.calculate_overlap(left_assignment),
77
+ )
78
+ .as_constraint("Room conflict")
79
+ )
80
+
81
+
82
+ def avoid_overtime(constraint_factory: ConstraintFactory) -> Constraint:
83
+ """
84
+ Hard constraint: Prevents meetings from extending beyond available time slots.
85
+
86
+ Penalizes meetings that end after the last available time grain, based on how far they extend beyond the schedule.
87
+
88
+ Args:
89
+ constraint_factory (ConstraintFactory): The constraint factory.
90
+ Returns:
91
+ Constraint: The defined constraint.
92
+ """
93
+ return (
94
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
95
+ .filter(
96
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
97
+ is not None
98
+ )
99
+ .if_not_exists(
100
+ TimeGrain,
101
+ Joiners.equal(
102
+ lambda assignment: assignment.get_last_time_grain_index(),
103
+ lambda time_grain: time_grain.grain_index,
104
+ ),
105
+ )
106
+ .penalize(
107
+ HardMediumSoftScore.ONE_HARD,
108
+ lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
109
+ )
110
+ .as_constraint("Don't go in overtime")
111
+ )
112
+
113
+
114
+ def required_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint:
115
+ """
116
+ Hard constraint: Prevents required attendees from having overlapping meetings.
117
+
118
+ Penalizes when a person required at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration.
119
+
120
+ Args:
121
+ constraint_factory (ConstraintFactory): The constraint factory.
122
+ Returns:
123
+ Constraint: The defined constraint.
124
+ """
125
+ return (
126
+ constraint_factory.for_each_unique_pair(
127
+ RequiredAttendance, Joiners.equal(lambda attendance: attendance.person)
128
+ )
129
+ .join(
130
+ MeetingAssignment,
131
+ Joiners.equal(
132
+ lambda left_required, right_required: left_required.meeting_id,
133
+ lambda assignment: assignment.meeting.id,
134
+ ),
135
+ )
136
+ .join(
137
+ MeetingAssignment,
138
+ Joiners.equal(
139
+ lambda left_required,
140
+ right_required,
141
+ left_assignment: right_required.meeting_id,
142
+ lambda assignment: assignment.meeting.id,
143
+ ),
144
+ Joiners.overlapping(
145
+ lambda attendee1, attendee2, assignment: assignment.get_grain_index(),
146
+ lambda attendee1,
147
+ attendee2,
148
+ assignment: assignment.get_last_time_grain_index() + 1,
149
+ lambda assignment: assignment.get_grain_index(),
150
+ lambda assignment: assignment.get_last_time_grain_index() + 1,
151
+ ),
152
+ )
153
+ .penalize(
154
+ HardMediumSoftScore.ONE_HARD,
155
+ lambda left_required,
156
+ right_required,
157
+ left_assignment,
158
+ right_assignment: right_assignment.calculate_overlap(left_assignment),
159
+ )
160
+ .as_constraint("Required attendance conflict")
161
+ )
162
+
163
+
164
+ def required_room_capacity(constraint_factory: ConstraintFactory) -> Constraint:
165
+ """
166
+ Hard constraint: Ensures rooms have enough capacity for required attendees.
167
+
168
+ Penalizes meetings assigned to rooms with insufficient capacity, proportional to the shortfall.
169
+
170
+ Args:
171
+ constraint_factory (ConstraintFactory): The constraint factory.
172
+ Returns:
173
+ Constraint: The defined constraint.
174
+ """
175
+ return (
176
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
177
+ .filter(
178
+ lambda meeting_assignment: meeting_assignment.get_required_capacity()
179
+ > meeting_assignment.get_room_capacity()
180
+ )
181
+ .penalize(
182
+ HardMediumSoftScore.ONE_HARD,
183
+ lambda meeting_assignment: meeting_assignment.get_required_capacity()
184
+ - meeting_assignment.get_room_capacity(),
185
+ )
186
+ .as_constraint("Required room capacity")
187
+ )
188
+
189
+
190
+ def start_and_end_on_same_day(constraint_factory: ConstraintFactory) -> Constraint:
191
+ """
192
+ Hard constraint: Ensures meetings start and end on the same day.
193
+
194
+ Penalizes meetings that span multiple days.
195
+
196
+ Args:
197
+ constraint_factory (ConstraintFactory): The constraint factory.
198
+ Returns:
199
+ Constraint: The defined constraint.
200
+ """
201
+ return (
202
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
203
+ .filter(
204
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
205
+ is not None
206
+ )
207
+ .join(
208
+ TimeGrain,
209
+ Joiners.equal(
210
+ lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
211
+ lambda time_grain: time_grain.grain_index,
212
+ ),
213
+ Joiners.filtering(
214
+ lambda meeting_assignment,
215
+ time_grain: meeting_assignment.starting_time_grain.day_of_year
216
+ != time_grain.day_of_year
217
+ ),
218
+ )
219
+ .penalize(HardMediumSoftScore.ONE_HARD)
220
+ .as_constraint("Start and end on same day")
221
+ )
222
+
223
+
224
+ # ************************************************************************
225
+ # Medium constraints
226
+ # ************************************************************************
227
+
228
+
229
+ def required_and_preferred_attendance_conflict(
230
+ constraint_factory: ConstraintFactory,
231
+ ) -> Constraint:
232
+ """
233
+ Medium constraint: Discourages conflicts between required and preferred attendance for the same person.
234
+
235
+ Penalizes when a person required at one meeting and preferred at another is scheduled for overlapping meetings, proportional to the overlap duration.
236
+
237
+ Args:
238
+ constraint_factory (ConstraintFactory): The constraint factory.
239
+ Returns:
240
+ Constraint: The defined constraint.
241
+ """
242
+ return (
243
+ constraint_factory.for_each(RequiredAttendance)
244
+ .join(
245
+ PreferredAttendance,
246
+ Joiners.equal(
247
+ lambda required: required.person, lambda preferred: preferred.person
248
+ ),
249
+ )
250
+ .join(
251
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
252
+ lambda assignment: assignment.starting_time_grain is not None
253
+ ),
254
+ Joiners.equal(
255
+ lambda required, preferred: required.meeting_id,
256
+ lambda assignment: assignment.meeting.id,
257
+ ),
258
+ )
259
+ .join(
260
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
261
+ lambda assignment: assignment.starting_time_grain is not None
262
+ ),
263
+ Joiners.equal(
264
+ lambda required, preferred, left_assignment: preferred.meeting_id,
265
+ lambda assignment: assignment.meeting.id,
266
+ ),
267
+ Joiners.overlapping(
268
+ lambda required, preferred, assignment: assignment.get_grain_index(),
269
+ lambda required,
270
+ preferred,
271
+ assignment: assignment.get_last_time_grain_index() + 1,
272
+ lambda assignment: assignment.get_grain_index(),
273
+ lambda assignment: assignment.get_last_time_grain_index() + 1,
274
+ ),
275
+ )
276
+ .penalize(
277
+ HardMediumSoftScore.ONE_MEDIUM,
278
+ lambda required,
279
+ preferred,
280
+ left_assignment,
281
+ right_assignment: right_assignment.calculate_overlap(left_assignment),
282
+ )
283
+ .as_constraint("Required and preferred attendance conflict")
284
+ )
285
+
286
+
287
+ def preferred_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint:
288
+ """
289
+ Medium constraint: Discourages conflicts between preferred attendees.
290
+
291
+ Penalizes when a person preferred at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration.
292
+
293
+ Args:
294
+ constraint_factory (ConstraintFactory): The constraint factory.
295
+ Returns:
296
+ Constraint: The defined constraint.
297
+ """
298
+ return (
299
+ constraint_factory.for_each_unique_pair(
300
+ PreferredAttendance, Joiners.equal(lambda attendance: attendance.person)
301
+ )
302
+ .join(
303
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
304
+ lambda assignment: assignment.starting_time_grain is not None
305
+ ),
306
+ Joiners.equal(
307
+ lambda left_attendance, right_attendance: left_attendance.meeting_id,
308
+ lambda assignment: assignment.meeting.id,
309
+ ),
310
+ )
311
+ .join(
312
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
313
+ lambda assignment: assignment.starting_time_grain is not None
314
+ ),
315
+ Joiners.equal(
316
+ lambda left_attendance,
317
+ right_attendance,
318
+ left_assignment: right_attendance.meeting_id,
319
+ lambda assignment: assignment.meeting.id,
320
+ ),
321
+ Joiners.overlapping(
322
+ lambda attendee1, attendee2, assignment: assignment.get_grain_index(),
323
+ lambda attendee1,
324
+ attendee2,
325
+ assignment: assignment.get_last_time_grain_index() + 1,
326
+ lambda assignment: assignment.get_grain_index(),
327
+ lambda assignment: assignment.get_last_time_grain_index() + 1,
328
+ ),
329
+ )
330
+ .penalize(
331
+ HardMediumSoftScore.ONE_MEDIUM,
332
+ lambda left_attendance,
333
+ right_attendance,
334
+ left_assignment,
335
+ right_assignment: right_assignment.calculate_overlap(left_assignment),
336
+ )
337
+ .as_constraint("Preferred attendance conflict")
338
+ )
339
+
340
+
341
+ # ************************************************************************
342
+ # Soft constraints
343
+ # ************************************************************************
344
+
345
+
346
+ def do_meetings_as_soon_as_possible(
347
+ constraint_factory: ConstraintFactory,
348
+ ) -> Constraint:
349
+ """
350
+ Soft constraint: Encourages scheduling meetings earlier in the available time slots.
351
+
352
+ Penalizes meetings scheduled later in the available time grains, proportional to their end time.
353
+
354
+ Args:
355
+ constraint_factory (ConstraintFactory): The constraint factory.
356
+ Returns:
357
+ Constraint: The defined constraint.
358
+ """
359
+ return (
360
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
361
+ .filter(
362
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
363
+ is not None
364
+ )
365
+ .penalize(
366
+ HardMediumSoftScore.ONE_SOFT,
367
+ lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
368
+ )
369
+ .as_constraint("Do all meetings as soon as possible")
370
+ )
371
+
372
+
373
+ def one_break_between_consecutive_meetings(
374
+ constraint_factory: ConstraintFactory,
375
+ ) -> Constraint:
376
+ """
377
+ Soft constraint: Penalizes consecutive meetings without a break.
378
+
379
+ Penalizes pairs of meetings that are scheduled consecutively without at least one time grain break between them.
380
+
381
+ Args:
382
+ constraint_factory (ConstraintFactory): The constraint factory.
383
+ Returns:
384
+ Constraint: The defined constraint.
385
+ """
386
+ return (
387
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
388
+ .filter(
389
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
390
+ is not None
391
+ )
392
+ .join(
393
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
394
+ lambda assignment: assignment.starting_time_grain is not None
395
+ ),
396
+ Joiners.equal(
397
+ lambda left_assignment: left_assignment.get_last_time_grain_index(),
398
+ lambda right_assignment: right_assignment.get_grain_index() - 1,
399
+ ),
400
+ )
401
+ .penalize(HardMediumSoftScore.of_soft(100))
402
+ .as_constraint("One TimeGrain break between two consecutive meetings")
403
+ )
404
+
405
+
406
+ def overlapping_meetings(constraint_factory: ConstraintFactory) -> Constraint:
407
+ """
408
+ Soft constraint: Discourages overlapping meetings, even in different rooms.
409
+
410
+ Penalizes pairs of meetings that overlap in time, regardless of room, proportional to the overlap duration.
411
+
412
+ Args:
413
+ constraint_factory (ConstraintFactory): The constraint factory.
414
+ Returns:
415
+ Constraint: The defined constraint.
416
+ """
417
+ return (
418
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
419
+ .filter(
420
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
421
+ is not None
422
+ )
423
+ .join(
424
+ constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
425
+ lambda meeting_assignment: meeting_assignment.starting_time_grain
426
+ is not None
427
+ ),
428
+ Joiners.greater_than(
429
+ lambda left_assignment: left_assignment.meeting.id,
430
+ lambda right_assignment: right_assignment.meeting.id,
431
+ ),
432
+ Joiners.overlapping(
433
+ lambda assignment: assignment.get_grain_index(),
434
+ lambda assignment: assignment.get_last_time_grain_index() + 1,
435
+ ),
436
+ )
437
+ .penalize(
438
+ HardMediumSoftScore.of_soft(10),
439
+ lambda left_assignment, right_assignment: left_assignment.calculate_overlap(
440
+ right_assignment
441
+ ),
442
+ )
443
+ .as_constraint("Overlapping meetings")
444
+ )
445
+
446
+
447
+ def assign_larger_rooms_first(constraint_factory: ConstraintFactory) -> Constraint:
448
+ """
449
+ Soft constraint: Penalizes using smaller rooms when larger rooms are available.
450
+
451
+ Penalizes when a meeting is assigned to a room while larger rooms exist, proportional to the capacity difference.
452
+
453
+ Args:
454
+ constraint_factory (ConstraintFactory): The constraint factory.
455
+ Returns:
456
+ Constraint: The defined constraint.
457
+ """
458
+ return (
459
+ constraint_factory.for_each_including_unassigned(MeetingAssignment)
460
+ .filter(lambda meeting_assignment: meeting_assignment.room is not None)
461
+ .join(
462
+ Room,
463
+ Joiners.less_than(
464
+ lambda meeting_assignment: meeting_assignment.get_room_capacity(),
465
+ lambda room: room.capacity,
466
+ ),
467
+ )
468
+ .penalize(
469
+ HardMediumSoftScore.ONE_SOFT,
470
+ lambda meeting_assignment, room: room.capacity
471
+ - meeting_assignment.get_room_capacity(),
472
+ )
473
+ .as_constraint("Assign larger rooms first")
474
+ )
475
+
476
+
477
+ def room_stability(constraint_factory: ConstraintFactory) -> Constraint:
478
+ """
479
+ Soft constraint: Encourages room stability for people attending multiple meetings.
480
+
481
+ Penalizes when a person attends meetings in different rooms that are close in time.
482
+ Uses weighted penalty: back-to-back room switches cost more than switches with gaps.
483
+ """
484
+ return (
485
+ constraint_factory.for_each(Attendance)
486
+ .join(
487
+ Attendance,
488
+ Joiners.equal(lambda a: a.person),
489
+ Joiners.filtering(lambda left, right: left.meeting_id != right.meeting_id),
490
+ )
491
+ .join(
492
+ MeetingAssignment,
493
+ Joiners.equal(
494
+ lambda left, right: left.meeting_id,
495
+ lambda assignment: assignment.meeting.id,
496
+ ),
497
+ )
498
+ .join(
499
+ MeetingAssignment,
500
+ Joiners.equal(
501
+ lambda left, right, left_assignment: right.meeting_id,
502
+ lambda assignment: assignment.meeting.id,
503
+ ),
504
+ Joiners.less_than(
505
+ lambda left, right, left_assignment: left_assignment.get_grain_index(),
506
+ lambda assignment: assignment.get_grain_index(),
507
+ ),
508
+ Joiners.filtering(
509
+ lambda left, right, left_assignment, right_assignment: left_assignment.room != right_assignment.room
510
+ ),
511
+ Joiners.filtering(
512
+ lambda left, right, left_assignment, right_assignment: right_assignment.get_grain_index()
513
+ - left_assignment.meeting.duration_in_grains
514
+ - left_assignment.get_grain_index()
515
+ <= 2
516
+ ),
517
+ )
518
+ .penalize(
519
+ HardMediumSoftScore.ONE_SOFT,
520
+ lambda left, right, left_assignment, right_assignment: 3 - (
521
+ right_assignment.get_grain_index()
522
+ - left_assignment.meeting.duration_in_grains
523
+ - left_assignment.get_grain_index()
524
+ ),
525
+ )
526
+ .as_constraint("Room stability")
527
+ )
src/meeting_scheduling/converters.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional, Union
2
+ from . import domain
3
+ from .json_serialization import JsonDomainBase
4
+ from pydantic import Field
5
+
6
+
7
+ # Pydantic models for API boundary
8
+ class PersonModel(JsonDomainBase):
9
+ id: str
10
+ full_name: str
11
+
12
+
13
+ class TimeGrainModel(JsonDomainBase):
14
+ id: str
15
+ grain_index: int
16
+ day_of_year: int
17
+ starting_minute_of_day: int
18
+
19
+
20
+ class RoomModel(JsonDomainBase):
21
+ id: str
22
+ name: str
23
+ capacity: int
24
+
25
+
26
+ class RequiredAttendanceModel(JsonDomainBase):
27
+ id: str
28
+ person: PersonModel
29
+ meeting_id: str = Field(..., alias="meeting")
30
+
31
+
32
+ class PreferredAttendanceModel(JsonDomainBase):
33
+ id: str
34
+ person: PersonModel
35
+ meeting_id: str = Field(..., alias="meeting")
36
+
37
+
38
+ class MeetingModel(JsonDomainBase):
39
+ id: str
40
+ topic: str
41
+ duration_in_grains: int
42
+ speakers: Optional[List[PersonModel]] = None
43
+ content: Optional[str] = None
44
+ entire_group_meeting: bool = False
45
+ required_attendances: List[RequiredAttendanceModel] = Field(
46
+ default_factory=list, alias="requiredAttendances"
47
+ )
48
+ preferred_attendances: List[PreferredAttendanceModel] = Field(
49
+ default_factory=list, alias="preferredAttendances"
50
+ )
51
+
52
+
53
+ class MeetingAssignmentModel(JsonDomainBase):
54
+ id: str
55
+ meeting: Union[str, MeetingModel]
56
+ pinned: bool = False
57
+ starting_time_grain: Union[str, TimeGrainModel, None] = None
58
+ room: Union[str, RoomModel, None] = None
59
+
60
+
61
+ class MeetingScheduleModel(JsonDomainBase):
62
+ people: List[PersonModel]
63
+ time_grains: List[TimeGrainModel] = Field(..., alias="timeGrains")
64
+ rooms: List[RoomModel]
65
+ meetings: List[MeetingModel]
66
+ required_attendances: List[RequiredAttendanceModel] = Field(
67
+ default_factory=list, alias="requiredAttendances"
68
+ )
69
+ preferred_attendances: List[PreferredAttendanceModel] = Field(
70
+ default_factory=list, alias="preferredAttendances"
71
+ )
72
+ meeting_assignments: List[MeetingAssignmentModel] = Field(
73
+ default_factory=list, alias="meetingAssignments"
74
+ )
75
+ score: Optional[str] = None
76
+ solver_status: Optional[str] = None
77
+
78
+
79
+ # Conversion functions from domain to API models
80
+ def person_to_model(person: domain.Person) -> PersonModel:
81
+ return PersonModel(id=person.id, full_name=person.full_name)
82
+
83
+
84
+ def time_grain_to_model(time_grain: domain.TimeGrain) -> TimeGrainModel:
85
+ return TimeGrainModel(
86
+ id=time_grain.id,
87
+ grain_index=time_grain.grain_index,
88
+ day_of_year=time_grain.day_of_year,
89
+ starting_minute_of_day=time_grain.starting_minute_of_day,
90
+ )
91
+
92
+
93
+ def room_to_model(room: domain.Room) -> RoomModel:
94
+ return RoomModel(id=room.id, name=room.name, capacity=room.capacity)
95
+
96
+
97
+ def required_attendance_to_model(
98
+ attendance: domain.RequiredAttendance,
99
+ ) -> RequiredAttendanceModel:
100
+ return RequiredAttendanceModel(
101
+ id=attendance.id,
102
+ person=person_to_model(attendance.person),
103
+ meeting_id=attendance.meeting_id,
104
+ )
105
+
106
+
107
+ def preferred_attendance_to_model(
108
+ attendance: domain.PreferredAttendance,
109
+ ) -> PreferredAttendanceModel:
110
+ return PreferredAttendanceModel(
111
+ id=attendance.id,
112
+ person=person_to_model(attendance.person),
113
+ meeting_id=attendance.meeting_id,
114
+ )
115
+
116
+
117
+ def meeting_to_model(meeting: domain.Meeting) -> MeetingModel:
118
+ return MeetingModel(
119
+ id=meeting.id,
120
+ topic=meeting.topic,
121
+ duration_in_grains=meeting.duration_in_grains,
122
+ speakers=[person_to_model(p) for p in meeting.speakers]
123
+ if meeting.speakers
124
+ else None,
125
+ content=meeting.content,
126
+ entire_group_meeting=meeting.entire_group_meeting,
127
+ required_attendances=[
128
+ required_attendance_to_model(ra) for ra in meeting.required_attendances
129
+ ],
130
+ preferred_attendances=[
131
+ preferred_attendance_to_model(pa) for pa in meeting.preferred_attendances
132
+ ],
133
+ )
134
+
135
+
136
+ def meeting_assignment_to_model(
137
+ assignment: domain.MeetingAssignment,
138
+ ) -> MeetingAssignmentModel:
139
+ return MeetingAssignmentModel(
140
+ id=assignment.id,
141
+ meeting=meeting_to_model(assignment.meeting),
142
+ pinned=assignment.pinned,
143
+ starting_time_grain=time_grain_to_model(assignment.starting_time_grain)
144
+ if assignment.starting_time_grain
145
+ else None,
146
+ room=room_to_model(assignment.room) if assignment.room else None,
147
+ )
148
+
149
+
150
+ def schedule_to_model(schedule: domain.MeetingSchedule) -> MeetingScheduleModel:
151
+ return MeetingScheduleModel(
152
+ people=[person_to_model(p) for p in schedule.people],
153
+ time_grains=[time_grain_to_model(tg) for tg in schedule.time_grains],
154
+ rooms=[room_to_model(r) for r in schedule.rooms],
155
+ meetings=[meeting_to_model(m) for m in schedule.meetings],
156
+ required_attendances=[
157
+ required_attendance_to_model(ra) for ra in schedule.required_attendances
158
+ ],
159
+ preferred_attendances=[
160
+ preferred_attendance_to_model(pa) for pa in schedule.preferred_attendances
161
+ ],
162
+ meeting_assignments=[
163
+ meeting_assignment_to_model(ma) for ma in schedule.meeting_assignments
164
+ ],
165
+ score=str(schedule.score) if schedule.score else None,
166
+ solver_status=schedule.solver_status.name if schedule.solver_status else None,
167
+ )
168
+
169
+
170
+ # Conversion functions from API models to domain
171
+ def model_to_person(model: PersonModel) -> domain.Person:
172
+ return domain.Person(id=model.id, full_name=model.full_name)
173
+
174
+
175
+ def model_to_time_grain(model: TimeGrainModel) -> domain.TimeGrain:
176
+ return domain.TimeGrain(
177
+ id=model.id,
178
+ grain_index=model.grain_index,
179
+ day_of_year=model.day_of_year,
180
+ starting_minute_of_day=model.starting_minute_of_day,
181
+ )
182
+
183
+
184
+ def model_to_room(model: RoomModel) -> domain.Room:
185
+ return domain.Room(id=model.id, name=model.name, capacity=model.capacity)
186
+
187
+
188
+ def model_to_required_attendance(
189
+ model: RequiredAttendanceModel,
190
+ ) -> domain.RequiredAttendance:
191
+ return domain.RequiredAttendance(
192
+ id=model.id, person=model_to_person(model.person), meeting_id=model.meeting_id
193
+ )
194
+
195
+
196
+ def model_to_preferred_attendance(
197
+ model: PreferredAttendanceModel,
198
+ ) -> domain.PreferredAttendance:
199
+ return domain.PreferredAttendance(
200
+ id=model.id, person=model_to_person(model.person), meeting_id=model.meeting_id
201
+ )
202
+
203
+
204
+ def model_to_meeting(model: MeetingModel) -> domain.Meeting:
205
+ return domain.Meeting(
206
+ id=model.id,
207
+ topic=model.topic,
208
+ duration_in_grains=model.duration_in_grains,
209
+ speakers=[model_to_person(p) for p in model.speakers]
210
+ if model.speakers
211
+ else None,
212
+ content=model.content,
213
+ entire_group_meeting=model.entire_group_meeting,
214
+ required_attendances=[
215
+ model_to_required_attendance(ra) for ra in model.required_attendances
216
+ ],
217
+ preferred_attendances=[
218
+ model_to_preferred_attendance(pa) for pa in model.preferred_attendances
219
+ ],
220
+ )
221
+
222
+
223
+ def model_to_meeting_assignment(
224
+ model: MeetingAssignmentModel,
225
+ meeting_lookup: dict,
226
+ room_lookup: dict,
227
+ time_grain_lookup: dict,
228
+ ) -> domain.MeetingAssignment:
229
+ # Handle meeting reference
230
+ if isinstance(model.meeting, str):
231
+ meeting = meeting_lookup[model.meeting]
232
+ else:
233
+ meeting = model_to_meeting(model.meeting)
234
+
235
+ # Handle room reference
236
+ room = None
237
+ if model.room:
238
+ if isinstance(model.room, str):
239
+ room = room_lookup[model.room]
240
+ else:
241
+ room = model_to_room(model.room)
242
+
243
+ # Handle time grain reference
244
+ starting_time_grain = None
245
+ if model.starting_time_grain:
246
+ if isinstance(model.starting_time_grain, str):
247
+ starting_time_grain = time_grain_lookup[model.starting_time_grain]
248
+ else:
249
+ starting_time_grain = model_to_time_grain(model.starting_time_grain)
250
+
251
+ return domain.MeetingAssignment(
252
+ id=model.id,
253
+ meeting=meeting,
254
+ pinned=model.pinned,
255
+ starting_time_grain=starting_time_grain,
256
+ room=room,
257
+ )
258
+
259
+
260
+ def model_to_schedule(model: MeetingScheduleModel) -> domain.MeetingSchedule:
261
+ # Convert basic collections first
262
+ people = [model_to_person(p) for p in model.people]
263
+ time_grains = [model_to_time_grain(tg) for tg in model.time_grains]
264
+ rooms = [model_to_room(r) for r in model.rooms]
265
+ meetings = [model_to_meeting(m) for m in model.meetings]
266
+
267
+ # Create lookup dictionaries for references
268
+ meeting_lookup = {m.id: m for m in meetings}
269
+ room_lookup = {r.id: r for r in rooms}
270
+ time_grain_lookup = {tg.id: tg for tg in time_grains}
271
+
272
+ # Convert meeting assignments with lookups
273
+ meeting_assignments = [
274
+ model_to_meeting_assignment(ma, meeting_lookup, room_lookup, time_grain_lookup)
275
+ for ma in model.meeting_assignments
276
+ ]
277
+
278
+ # Convert attendances
279
+ required_attendances = [
280
+ model_to_required_attendance(ra) for ra in model.required_attendances
281
+ ]
282
+ preferred_attendances = [
283
+ model_to_preferred_attendance(pa) for pa in model.preferred_attendances
284
+ ]
285
+
286
+ # Build combined attendances list for room_stability constraint
287
+ people_lookup = {p.id: p for p in people}
288
+ attendances = []
289
+ for ra in required_attendances:
290
+ attendances.append(domain.Attendance(
291
+ id=ra.id,
292
+ person=ra.person,
293
+ meeting_id=ra.meeting_id,
294
+ ))
295
+ for pa in preferred_attendances:
296
+ attendances.append(domain.Attendance(
297
+ id=pa.id,
298
+ person=pa.person,
299
+ meeting_id=pa.meeting_id,
300
+ ))
301
+
302
+ # Handle score
303
+ score = None
304
+ if model.score:
305
+ from solverforge_legacy.solver.score import HardMediumSoftScore
306
+
307
+ score = HardMediumSoftScore.parse(model.score)
308
+
309
+ # Handle solver status
310
+ solver_status = domain.SolverStatus.NOT_SOLVING
311
+ if model.solver_status:
312
+ solver_status = domain.SolverStatus[model.solver_status]
313
+
314
+ return domain.MeetingSchedule(
315
+ people=people,
316
+ time_grains=time_grains,
317
+ rooms=rooms,
318
+ meetings=meetings,
319
+ required_attendances=required_attendances,
320
+ preferred_attendances=preferred_attendances,
321
+ attendances=attendances,
322
+ meeting_assignments=meeting_assignments,
323
+ score=score,
324
+ solver_status=solver_status,
325
+ )
src/meeting_scheduling/demo_data.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from random import Random
2
+ from datetime import datetime, timedelta
3
+ from enum import Enum
4
+ from typing import List
5
+ from dataclasses import dataclass
6
+
7
+ from .domain import Person, TimeGrain, Room, Meeting, MeetingAssignment, MeetingSchedule, RequiredAttendance, PreferredAttendance
8
+
9
+ class DemoData(str, Enum):
10
+ SMALL = "SMALL"
11
+ MEDIUM = "MEDIUM"
12
+ LARGE = "LARGE"
13
+
14
+ @dataclass(frozen=True, kw_only=True)
15
+ class CountDistribution:
16
+ count: int
17
+ weight: float
18
+
19
+ def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]:
20
+ return tuple(distribution.count for distribution in distributions)
21
+
22
+ def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]:
23
+ return tuple(distribution.weight for distribution in distributions)
24
+
25
+ def generate_demo_data() -> MeetingSchedule:
26
+ """Generate demo data for the meeting scheduling problem."""
27
+ rnd = Random(0) # For reproducible results
28
+
29
+ # People
30
+ people = generate_people(20, rnd)
31
+
32
+ # Time grains
33
+ time_grains = generate_time_grains()
34
+
35
+ # Rooms
36
+ rooms = [
37
+ Room(id="R1", name="Room 1", capacity=30),
38
+ Room(id="R2", name="Room 2", capacity=20),
39
+ Room(id="R3", name="Room 3", capacity=16)
40
+ ]
41
+
42
+ # Meetings
43
+ meetings = generate_meetings(people, rnd)
44
+
45
+ # Rebuild meetings with correct attendances
46
+ all_required_attendances = [ra for meeting in meetings for ra in meeting.required_attendances]
47
+ all_preferred_attendances = [pa for meeting in meetings for pa in meeting.preferred_attendances]
48
+ new_meetings = []
49
+ for m in meetings:
50
+ new_meetings.append(
51
+ type(m)(
52
+ id=m.id,
53
+ topic=m.topic,
54
+ duration_in_grains=m.duration_in_grains,
55
+ speakers=m.speakers,
56
+ content=m.content or "",
57
+ entire_group_meeting=m.entire_group_meeting,
58
+ required_attendances=[a for a in all_required_attendances if a.meeting_id == m.id],
59
+ preferred_attendances=[a for a in all_preferred_attendances if a.meeting_id == m.id],
60
+ )
61
+ )
62
+ meetings = new_meetings
63
+
64
+ # Meeting assignments
65
+ meeting_assignments = generate_meeting_assignments(meetings)
66
+
67
+ # Create schedule
68
+ schedule = MeetingSchedule(
69
+ people=people,
70
+ time_grains=time_grains,
71
+ rooms=rooms,
72
+ meetings=meetings,
73
+ meeting_assignments=meeting_assignments,
74
+ required_attendances=[ra for meeting in meetings for ra in meeting.required_attendances],
75
+ preferred_attendances=[pa for meeting in meetings for pa in meeting.preferred_attendances],
76
+ )
77
+
78
+ return schedule
79
+
80
+
81
+ def generate_people(count_people: int, rnd: Random) -> List[Person]:
82
+ """Generate a list of people."""
83
+ FIRST_NAMES = ["Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay",
84
+ "Jeri", "Hope", "Avis", "Lino", "Lyle", "Nick", "Dino", "Otha", "Gwen", "Jose",
85
+ "Dena", "Jana", "Dave", "Russ", "Josh", "Dana", "Katy"]
86
+ LAST_NAMES = ["Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt",
87
+ "Howe", "Lowe", "Wise", "Clay", "Carr", "Hood", "Long", "Horn", "Haas", "Meza"]
88
+
89
+ def generate_name() -> str:
90
+ first_name = rnd.choice(FIRST_NAMES)
91
+ last_name = rnd.choice(LAST_NAMES)
92
+ return f"{first_name} {last_name}"
93
+
94
+ return [Person(id=str(i), full_name=generate_name()) for i in range(count_people)]
95
+
96
+
97
+ def generate_time_grains() -> List[TimeGrain]:
98
+ """Generate time grains for the next 4 days starting from tomorrow."""
99
+ time_grains = []
100
+ current_date = datetime.now().date() + timedelta(days=1)
101
+ count = 0
102
+
103
+ while current_date < datetime.now().date() + timedelta(days=5): # Match Java: from +1 to +5 (4 days)
104
+ current_time = datetime.combine(current_date, datetime.min.time()) + timedelta(hours=8) # Start at 8:00
105
+ end_time = datetime.combine(current_date, datetime.min.time()) + timedelta(hours=17, minutes=45) # End at 17:45
106
+
107
+ while current_time <= end_time:
108
+ day_of_year = current_date.timetuple().tm_yday
109
+ minutes_of_day = current_time.hour * 60 + current_time.minute
110
+
111
+ count += 1 # Pre-increment like Java ++count
112
+ time_grains.append(TimeGrain(
113
+ id=str(count),
114
+ grain_index=count,
115
+ day_of_year=day_of_year,
116
+ starting_minute_of_day=minutes_of_day
117
+ ))
118
+ current_time += timedelta(minutes=15) # 15-minute increments
119
+
120
+ current_date += timedelta(days=1)
121
+
122
+ return time_grains
123
+
124
+
125
+ def generate_meetings(people: List[Person], rnd: Random) -> List[Meeting]:
126
+ """Generate meetings with topics and attendees."""
127
+ meeting_topics = [
128
+ "Strategize B2B", "Fast track e-business", "Cross sell virtualization",
129
+ "Profitize multitasking", "Transform one stop shop", "Engage braindumps",
130
+ "Downsize data mining", "Ramp up policies", "On board synergies",
131
+ "Reinvigorate user experience", "Strategize e-business", "Fast track virtualization",
132
+ "Cross sell multitasking", "Profitize one stop shop", "Transform braindumps",
133
+ "Engage data mining", "Downsize policies", "Ramp up synergies",
134
+ "On board user experience", "Reinvigorate B2B", "Strategize virtualization",
135
+ "Fast track multitasking", "Cross sell one stop shop", "Reinvigorate multitasking"
136
+ ]
137
+
138
+ meetings = []
139
+ for i, topic in enumerate(meeting_topics):
140
+ meeting = Meeting(id=str(i), topic=topic, duration_in_grains=0)
141
+ meetings.append(meeting)
142
+
143
+ # Set durations using CountDistribution and random.choices
144
+ duration_distribution = (
145
+ CountDistribution(count=8, weight=1), # 33% with 8 time grains
146
+ CountDistribution(count=12, weight=1), # 33% with 12 time grains
147
+ CountDistribution(count=16, weight=1) # 33% with 16 time grains
148
+ )
149
+
150
+ for meeting in meetings:
151
+ duration_time_grains, = rnd.choices(population=counts(duration_distribution),
152
+ weights=weights(duration_distribution))
153
+ meeting.duration_in_grains = duration_time_grains
154
+
155
+ # Add required attendees using CountDistribution - slightly reduced to make more feasible
156
+ required_attendees_distribution = (
157
+ CountDistribution(count=2, weight=0.45), # More 2-person meetings
158
+ CountDistribution(count=3, weight=0.15), # More 3-person meetings
159
+ CountDistribution(count=4, weight=0.10), # Increased 4-person
160
+ CountDistribution(count=5, weight=0.10), # Slightly more 5-person
161
+ CountDistribution(count=6, weight=0.08), # Reduced larger meetings
162
+ CountDistribution(count=7, weight=0.05),
163
+ CountDistribution(count=8, weight=0.04),
164
+ CountDistribution(count=10, weight=0.03) # Reduced 10-person meetings
165
+ )
166
+
167
+ def add_required_attendees(meeting: Meeting, count: int) -> None:
168
+ # Use random.sample to avoid duplicates
169
+ selected_people = rnd.sample(people, count)
170
+ for person in selected_people:
171
+ meeting.required_attendances.append(
172
+ RequiredAttendance(
173
+ id=f"{meeting.id}-{len(meeting.required_attendances) + 1}",
174
+ person=person,
175
+ meeting_id=meeting.id
176
+ )
177
+ )
178
+
179
+ for meeting in meetings:
180
+ count, = rnd.choices(population=counts(required_attendees_distribution),
181
+ weights=weights(required_attendees_distribution))
182
+ add_required_attendees(meeting, count)
183
+
184
+ # Add preferred attendees using CountDistribution - reduced to make more feasible
185
+ preferred_attendees_distribution = (
186
+ CountDistribution(count=1, weight=0.25), # More 1-person preferred
187
+ CountDistribution(count=2, weight=0.30), # More 2-person preferred
188
+ CountDistribution(count=3, weight=0.20), # More 3-person preferred
189
+ CountDistribution(count=4, weight=0.10), # Increased 4-person
190
+ CountDistribution(count=5, weight=0.06), # Slightly more 5-person
191
+ CountDistribution(count=6, weight=0.04), # Reduced larger groups
192
+ CountDistribution(count=7, weight=0.02), # Reduced
193
+ CountDistribution(count=8, weight=0.02), # Reduced
194
+ CountDistribution(count=9, weight=0.01), # Minimal large groups
195
+ CountDistribution(count=10, weight=0.00) # Eliminated 10-person preferred
196
+ )
197
+
198
+ def add_preferred_attendees(meeting: Meeting, count: int) -> None:
199
+ # Get people not already required for this meeting
200
+ required_people_ids = {ra.person.id for ra in meeting.required_attendances}
201
+ available_people = [person for person in people if person.id not in required_people_ids]
202
+
203
+ # Use random.sample to avoid duplicates, but only if we have enough people
204
+ if len(available_people) >= count:
205
+ selected_people = rnd.sample(available_people, count)
206
+ for person in selected_people:
207
+ meeting.preferred_attendances.append(
208
+ PreferredAttendance(
209
+ id=f"{meeting.id}-{len(meeting.required_attendances) + len(meeting.preferred_attendances) + 1}",
210
+ person=person,
211
+ meeting_id=meeting.id
212
+ )
213
+ )
214
+
215
+ for meeting in meetings:
216
+ count, = rnd.choices(population=counts(preferred_attendees_distribution),
217
+ weights=weights(preferred_attendees_distribution))
218
+ add_preferred_attendees(meeting, count)
219
+
220
+ return meetings
221
+
222
+
223
+ def generate_meeting_assignments(meetings: List[Meeting]) -> List[MeetingAssignment]:
224
+ """Generate meeting assignments for each meeting."""
225
+ return [MeetingAssignment(id=str(i), meeting=meeting) for i, meeting in enumerate(meetings)]
src/meeting_scheduling/domain.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional, Annotated, Union
3
+ from solverforge_legacy.solver.domain import (
4
+ planning_entity,
5
+ planning_solution,
6
+ PlanningId,
7
+ PlanningVariable,
8
+ PlanningEntityCollectionProperty,
9
+ ProblemFactCollectionProperty,
10
+ ValueRangeProvider,
11
+ PlanningScore,
12
+ PlanningPin,
13
+ )
14
+ from solverforge_legacy.solver import SolverStatus
15
+ from solverforge_legacy.solver.score import HardMediumSoftScore
16
+ from .json_serialization import JsonDomainBase
17
+ from pydantic import Field
18
+
19
+ # Time granularity is 15 minutes (which is often recommended when dealing with humans for practical purposes).
20
+ GRAIN_LENGTH_IN_MINUTES = 15
21
+
22
+
23
+ @dataclass
24
+ class Person:
25
+ id: Annotated[str, PlanningId]
26
+ full_name: str
27
+
28
+
29
+ @dataclass
30
+ class TimeGrain:
31
+ id: Annotated[str, PlanningId]
32
+ grain_index: int
33
+ day_of_year: int
34
+ starting_minute_of_day: int
35
+
36
+
37
+ @dataclass
38
+ class Room:
39
+ id: Annotated[str, PlanningId]
40
+ name: str
41
+ capacity: int
42
+
43
+
44
+ # Define Attendance base class and subclasses before Meeting to avoid forward references
45
+ @dataclass
46
+ class Attendance:
47
+ id: Annotated[str, PlanningId]
48
+ person: Person
49
+ meeting_id: str
50
+
51
+
52
+ @dataclass
53
+ class RequiredAttendance:
54
+ id: Annotated[str, PlanningId]
55
+ person: Person
56
+ meeting_id: str
57
+
58
+
59
+ @dataclass
60
+ class PreferredAttendance:
61
+ id: Annotated[str, PlanningId]
62
+ person: Person
63
+ meeting_id: str
64
+
65
+
66
+ @dataclass
67
+ class Meeting:
68
+ id: Annotated[str, PlanningId]
69
+ topic: str
70
+ duration_in_grains: int
71
+ speakers: Optional[List[Person]] = None
72
+ content: Optional[str] = None
73
+ entire_group_meeting: bool = False
74
+ required_attendances: List[RequiredAttendance] = field(default_factory=list)
75
+ preferred_attendances: List[PreferredAttendance] = field(default_factory=list)
76
+
77
+ def get_required_capacity(self) -> int:
78
+ return len(self.required_attendances) + len(self.preferred_attendances)
79
+
80
+ def add_required_attendant(self, person: Person) -> None:
81
+ person_id = person.id
82
+ for r in self.required_attendances:
83
+ if r.person.id == person_id:
84
+ raise ValueError(
85
+ f"The person {person_id} is already assigned to the meeting {self.id}."
86
+ )
87
+ self.required_attendances.append(
88
+ RequiredAttendance(
89
+ id=f"{self.id}-{self.get_required_capacity() + 1}",
90
+ meeting_id=self.id,
91
+ person=person,
92
+ )
93
+ )
94
+
95
+ def add_preferred_attendant(self, person: Person) -> None:
96
+ person_id = person.id
97
+ for p in self.preferred_attendances:
98
+ if p.person.id == person_id:
99
+ raise ValueError(
100
+ f"The person {person_id} is already assigned to the meeting {self.id}."
101
+ )
102
+ self.preferred_attendances.append(
103
+ PreferredAttendance(
104
+ id=f"{self.id}-{self.get_required_capacity() + 1}",
105
+ meeting_id=self.id,
106
+ person=person,
107
+ )
108
+ )
109
+
110
+
111
+ @planning_entity
112
+ @dataclass
113
+ class MeetingAssignment:
114
+ id: Annotated[str, PlanningId]
115
+ meeting: Meeting
116
+ pinned: Annotated[bool, PlanningPin] = False
117
+ starting_time_grain: Annotated[Optional[TimeGrain], PlanningVariable] = None
118
+ room: Annotated[Optional[Room], PlanningVariable] = None
119
+
120
+ def get_grain_index(self) -> Optional[int]:
121
+ if self.starting_time_grain is None:
122
+ return None
123
+ return self.starting_time_grain.grain_index
124
+
125
+ def calculate_overlap(self, other: "MeetingAssignment") -> int:
126
+ if self.starting_time_grain is None or other.starting_time_grain is None:
127
+ return 0
128
+ # start is inclusive, end is exclusive
129
+ start = self.starting_time_grain.grain_index
130
+ end = self.get_last_time_grain_index() + 1
131
+ other_start = other.starting_time_grain.grain_index
132
+ other_end = other.get_last_time_grain_index() + 1
133
+
134
+ if other_end < start or end < other_start:
135
+ return 0
136
+
137
+ return min(end, other_end) - max(start, other_start)
138
+
139
+ def get_last_time_grain_index(self) -> Optional[int]:
140
+ if self.starting_time_grain is None:
141
+ return None
142
+ return (
143
+ self.starting_time_grain.grain_index + self.meeting.duration_in_grains - 1
144
+ )
145
+
146
+ def get_room_capacity(self) -> int:
147
+ if self.room is None:
148
+ return 0
149
+ return self.room.capacity
150
+
151
+ def get_required_capacity(self) -> int:
152
+ return self.meeting.get_required_capacity()
153
+
154
+
155
+ @planning_solution
156
+ @dataclass
157
+ class MeetingSchedule:
158
+ people: Annotated[List[Person], ProblemFactCollectionProperty]
159
+ time_grains: Annotated[
160
+ List[TimeGrain], ProblemFactCollectionProperty, ValueRangeProvider
161
+ ]
162
+ rooms: Annotated[List[Room], ProblemFactCollectionProperty, ValueRangeProvider]
163
+ meetings: Annotated[List[Meeting], ProblemFactCollectionProperty]
164
+ required_attendances: Annotated[
165
+ List[RequiredAttendance], ProblemFactCollectionProperty
166
+ ] = field(default_factory=list)
167
+ preferred_attendances: Annotated[
168
+ List[PreferredAttendance], ProblemFactCollectionProperty
169
+ ] = field(default_factory=list)
170
+ attendances: Annotated[
171
+ List[Attendance], ProblemFactCollectionProperty
172
+ ] = field(default_factory=list)
173
+ meeting_assignments: Annotated[
174
+ List[MeetingAssignment], PlanningEntityCollectionProperty
175
+ ] = field(default_factory=list)
176
+ score: Annotated[Optional[HardMediumSoftScore], PlanningScore] = None
177
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
178
+
179
+
180
+ # Pydantic REST models for API (used for deserialization and context)
181
+ class PersonModel(JsonDomainBase):
182
+ id: str
183
+ full_name: str
184
+
185
+
186
+ class TimeGrainModel(JsonDomainBase):
187
+ id: str
188
+ grain_index: int
189
+ day_of_year: int
190
+ starting_minute_of_day: int
191
+
192
+
193
+ class RoomModel(JsonDomainBase):
194
+ id: str
195
+ name: str
196
+ capacity: int
197
+
198
+
199
+ class RequiredAttendanceModel(JsonDomainBase):
200
+ id: str
201
+ person: PersonModel
202
+ meeting_id: str = Field(..., alias="meeting")
203
+
204
+
205
+ class PreferredAttendanceModel(JsonDomainBase):
206
+ id: str
207
+ person: PersonModel
208
+ meeting_id: str = Field(..., alias="meeting")
209
+
210
+
211
+ class MeetingModel(JsonDomainBase):
212
+ id: str
213
+ topic: str
214
+ duration_in_grains: int
215
+ speakers: Optional[List[PersonModel]] = None
216
+ content: Optional[str] = None
217
+ entire_group_meeting: bool = False
218
+ required_attendances: List[RequiredAttendanceModel] = Field(
219
+ default_factory=list, alias="requiredAttendances"
220
+ )
221
+ preferred_attendances: List[PreferredAttendanceModel] = Field(
222
+ default_factory=list, alias="preferredAttendances"
223
+ )
224
+
225
+
226
+ class MeetingAssignmentModel(JsonDomainBase):
227
+ id: str
228
+ meeting: Union[str, MeetingModel]
229
+ pinned: bool = False
230
+ starting_time_grain: Union[str, TimeGrainModel, None] = None
231
+ room: Union[str, RoomModel, None] = None
232
+
233
+
234
+ class MeetingScheduleModel(JsonDomainBase):
235
+ people: List[PersonModel]
236
+ time_grains: List[TimeGrainModel]
237
+ rooms: List[RoomModel]
238
+ meetings: List[MeetingModel]
239
+ required_attendances: List[RequiredAttendanceModel] = Field(
240
+ default_factory=list, alias="requiredAttendances"
241
+ )
242
+ preferred_attendances: List[PreferredAttendanceModel] = Field(
243
+ default_factory=list, alias="preferredAttendances"
244
+ )
245
+ meeting_assignments: List[MeetingAssignmentModel] = Field(default_factory=list)
246
+ score: Optional[str] = None
247
+ solver_status: Optional[str] = None
src/meeting_scheduling/json_serialization.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import HardMediumSoftScore
2
+ from pydantic import (
3
+ BaseModel,
4
+ ConfigDict,
5
+ PlainSerializer,
6
+ BeforeValidator,
7
+ ValidationInfo,
8
+ )
9
+ from pydantic.alias_generators import to_camel
10
+ from typing import Any
11
+
12
+
13
+ def make_list_item_validator(key: str):
14
+ def validator(v: Any, info: ValidationInfo) -> Any:
15
+ if v is None:
16
+ return None
17
+
18
+ if not isinstance(v, str) or not info.context:
19
+ return v
20
+
21
+ return info.context.get(key, {}).get(v, v)
22
+
23
+ return BeforeValidator(validator)
24
+
25
+
26
+ # Validators for foreign key references
27
+ MeetingDeserializer = make_list_item_validator("meetings")
28
+ RoomDeserializer = make_list_item_validator("rooms")
29
+ TimeGrainDeserializer = make_list_item_validator("timeGrains")
30
+
31
+ IdSerializer = PlainSerializer(
32
+ lambda item: item.id if item is not None else None, return_type=str | None
33
+ )
34
+ ScoreSerializer = PlainSerializer(
35
+ lambda score: str(score) if score is not None else None, return_type=str | None
36
+ )
37
+
38
+
39
+ def validate_score(v: Any, info: ValidationInfo) -> Any:
40
+ if isinstance(v, HardMediumSoftScore) or v is None:
41
+ return v
42
+ if isinstance(v, str):
43
+ return HardMediumSoftScore.parse(v)
44
+ raise ValueError('"score" should be a string')
45
+
46
+
47
+ ScoreValidator = BeforeValidator(validate_score)
48
+
49
+
50
+ class JsonDomainBase(BaseModel):
51
+ model_config = ConfigDict(
52
+ alias_generator=to_camel,
53
+ populate_by_name=True,
54
+ from_attributes=True,
55
+ )
src/meeting_scheduling/rest_api.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.staticfiles import StaticFiles
3
+ from typing import Dict, List
4
+ from uuid import uuid4
5
+
6
+ from .domain import MeetingSchedule
7
+ from .converters import MeetingScheduleModel, schedule_to_model, model_to_schedule
8
+ from .demo_data import generate_demo_data
9
+ from .solver import solver_manager, solution_manager
10
+ from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
11
+
12
+ app = FastAPI(docs_url="/q/swagger-ui")
13
+
14
+ # Dictionary to store submitted data sets (using domain models internally)
15
+ data_sets: Dict[str, MeetingSchedule] = {}
16
+
17
+
18
+ @app.get("/demo-data")
19
+ async def list_demo_data() -> List[str]:
20
+ """List available demo data sets."""
21
+ return ["SMALL", "MEDIUM", "LARGE"]
22
+
23
+
24
+ @app.get("/demo-data/{dataset_id}")
25
+ async def get_demo_data(dataset_id: str) -> MeetingScheduleModel:
26
+ """Get a demo data set by ID."""
27
+ domain_schedule = generate_demo_data()
28
+ return schedule_to_model(domain_schedule)
29
+
30
+
31
+ @app.get("/schedules")
32
+ async def list_schedules() -> List[str]:
33
+ """List all job IDs of submitted schedules."""
34
+ return list(data_sets.keys())
35
+
36
+
37
+ @app.get("/schedules/{schedule_id}")
38
+ async def get_schedule(schedule_id: str) -> MeetingScheduleModel:
39
+ """Get the solution and score for a given job ID."""
40
+ if schedule_id not in data_sets:
41
+ raise ValueError(f"No schedule found with ID {schedule_id}")
42
+
43
+ schedule = data_sets[schedule_id]
44
+ solver_status = solver_manager.get_solver_status(schedule_id)
45
+ schedule.solver_status = solver_status
46
+
47
+ return schedule_to_model(schedule)
48
+
49
+
50
+ @app.get("/schedules/{problem_id}/status")
51
+ async def get_status(problem_id: str) -> Dict:
52
+ """Get the schedule status and score for a given job ID."""
53
+ if problem_id not in data_sets:
54
+ raise ValueError(f"No schedule found with ID {problem_id}")
55
+
56
+ schedule = data_sets[problem_id]
57
+ solver_status = solver_manager.get_solver_status(problem_id)
58
+
59
+ return {
60
+ "score": {
61
+ "hardScore": schedule.score.hard_score if schedule.score else 0,
62
+ "mediumScore": schedule.score.medium_score if schedule.score else 0,
63
+ "softScore": schedule.score.soft_score if schedule.score else 0,
64
+ },
65
+ "solverStatus": solver_status.name,
66
+ }
67
+
68
+
69
+ @app.delete("/schedules/{problem_id}")
70
+ async def terminate_solving(problem_id: str) -> MeetingScheduleModel:
71
+ """Terminate solving for a given job ID."""
72
+ if problem_id not in data_sets:
73
+ raise ValueError(f"No schedule found with ID {problem_id}")
74
+
75
+ try:
76
+ solver_manager.terminate_early(problem_id)
77
+ except Exception as e:
78
+ print(f"Warning: terminate_early failed for {problem_id}: {e}")
79
+
80
+ return await get_schedule(problem_id)
81
+
82
+
83
+ def update_schedule(problem_id: str, schedule: MeetingSchedule) -> None:
84
+ """Update the schedule in the data sets."""
85
+ global data_sets
86
+ data_sets[problem_id] = schedule
87
+
88
+
89
+ @app.post("/schedules")
90
+ async def solve_schedule(request: Request) -> str:
91
+ json_data = await request.json()
92
+ job_id = str(uuid4())
93
+
94
+ # Parse the incoming JSON using Pydantic models
95
+ schedule_model = MeetingScheduleModel.model_validate(json_data)
96
+
97
+ # Convert to domain model for solver
98
+ domain_schedule = model_to_schedule(schedule_model)
99
+
100
+ data_sets[job_id] = domain_schedule
101
+ solver_manager.solve_and_listen(
102
+ job_id, domain_schedule, lambda solution: update_schedule(job_id, solution)
103
+ )
104
+ return job_id
105
+
106
+
107
+ @app.put("/schedules/analyze")
108
+ async def analyze_schedule(request: Request) -> Dict:
109
+ """Submit a schedule to analyze its score."""
110
+ json_data = await request.json()
111
+
112
+ # Parse the incoming JSON using Pydantic models
113
+ schedule_model = MeetingScheduleModel.model_validate(json_data)
114
+
115
+ # Convert to domain model for analysis
116
+ domain_schedule = model_to_schedule(schedule_model)
117
+
118
+ analysis = solution_manager.analyze(domain_schedule)
119
+
120
+ def serialize_justification(justification):
121
+ """Convert justification facts to serializable dicts."""
122
+ if justification is None:
123
+ return None
124
+ facts = []
125
+ for fact in getattr(justification, 'facts', []):
126
+ fact_dict = {'id': getattr(fact, 'id', None)}
127
+ # MeetingAssignment - has meeting attribute
128
+ if hasattr(fact, 'meeting'):
129
+ fact_dict['type'] = 'assignment'
130
+ fact_dict['meeting'] = getattr(fact.meeting, 'id', None) if fact.meeting else None
131
+ # RequiredAttendance/PreferredAttendance - has person and meeting_id
132
+ elif hasattr(fact, 'person') and hasattr(fact, 'meeting_id'):
133
+ fact_dict['type'] = 'attendance'
134
+ fact_dict['personId'] = getattr(fact.person, 'id', None) if fact.person else None
135
+ fact_dict['meetingId'] = fact.meeting_id
136
+ facts.append(fact_dict)
137
+ return {'facts': facts}
138
+
139
+ # Convert to proper DTOs for correct serialization
140
+ constraints = []
141
+ for constraint in analysis.constraint_analyses:
142
+ matches = [
143
+ MatchAnalysisDTO(
144
+ name=match.constraint_ref.constraint_name,
145
+ score=match.score,
146
+ justification=serialize_justification(match.justification),
147
+ )
148
+ for match in constraint.matches
149
+ ]
150
+
151
+ constraint_dto = ConstraintAnalysisDTO(
152
+ name=constraint.constraint_name,
153
+ weight=constraint.weight,
154
+ score=constraint.score,
155
+ matches=matches,
156
+ )
157
+ constraints.append(constraint_dto)
158
+
159
+ return {"constraints": [constraint.model_dump() for constraint in constraints]}
160
+
161
+
162
+ # Mount static files
163
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/meeting_scheduling/score_analysis.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Any, Annotated
3
+ from solverforge_legacy.solver.score import HardMediumSoftScore
4
+ from .json_serialization import ScoreSerializer
5
+
6
+
7
+ class MatchAnalysisDTO(BaseModel):
8
+ name: str
9
+ score: Annotated[HardMediumSoftScore, ScoreSerializer]
10
+ justification: Any
11
+
12
+
13
+ class ConstraintAnalysisDTO(BaseModel):
14
+ name: str
15
+ weight: Annotated[HardMediumSoftScore, ScoreSerializer]
16
+ score: Annotated[HardMediumSoftScore, ScoreSerializer]
17
+ matches: List[MatchAnalysisDTO]
src/meeting_scheduling/solver.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverManager, SolverFactory, SolutionManager
2
+ from solverforge_legacy.solver.config import (
3
+ SolverConfig,
4
+ ScoreDirectorFactoryConfig,
5
+ TerminationConfig,
6
+ Duration,
7
+ )
8
+
9
+ from .domain import MeetingSchedule, MeetingAssignment
10
+ from .constraints import define_constraints
11
+
12
+ solver_config = SolverConfig(
13
+ solution_class=MeetingSchedule,
14
+ entity_class_list=[MeetingAssignment],
15
+ score_director_factory_config=ScoreDirectorFactoryConfig(
16
+ constraint_provider_function=define_constraints
17
+ ),
18
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
19
+ )
20
+
21
+ solver_manager = SolverManager.create(SolverFactory.create(solver_config))
22
+ solution_manager = SolutionManager.create(solver_manager)
static/app.js ADDED
@@ -0,0 +1,1023 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let autoRefreshIntervalId = null;
2
+ const formatter = JSJoda.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
3
+ const startTime = formatter.format(JSJoda.LocalDateTime.now().withHour(20).withMinute(0).withSecond(0));
4
+ const endTime = formatter.format(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).withMinute(0).withSecond(0));
5
+ const zoomMin = 1000 * 60 * 60 // one hour in milliseconds
6
+ const zoomMax = 4 * 1000 * 60 * 60 * 24 // 5 days in milliseconds
7
+
8
+ const byTimelineOptions = {
9
+ timeAxis: {scale: "hour", step: 1},
10
+ orientation: {axis: "top"},
11
+ stack: false,
12
+ xss: {disabled: true}, // Items are XSS safe through JQuery
13
+ zoomMin: zoomMin,
14
+ zoomMax: zoomMax,
15
+ showCurrentTime: false,
16
+ hiddenDates: [
17
+ {
18
+ start: startTime,
19
+ end: endTime,
20
+ repeat: 'daily'
21
+ }
22
+ ],
23
+ };
24
+
25
+ const byRoomPanel = document.getElementById("byRoomPanel");
26
+ let byRoomGroupData = new vis.DataSet();
27
+ let byRoomItemData = new vis.DataSet();
28
+ let byRoomTimeline = new vis.Timeline(byRoomPanel, byRoomItemData, byRoomGroupData, byTimelineOptions);
29
+
30
+ const byPersonPanel = document.getElementById("byPersonPanel");
31
+ let byPersonGroupData = new vis.DataSet();
32
+ let byPersonItemData = new vis.DataSet();
33
+ let byPersonTimeline = new vis.Timeline(byPersonPanel, byPersonItemData, byPersonGroupData, byTimelineOptions);
34
+
35
+ let scheduleId = null;
36
+ let loadedSchedule = null;
37
+ let viewType = "R";
38
+ let selectedDemoData = "MEDIUM"; // Default demo data size
39
+ let analyzeCache = null; // Cache for solver's constraint analysis (assignmentId -> violations)
40
+
41
+
42
+ let appInitialized = false;
43
+
44
+ $(document).ready(function () {
45
+ // Ensure all resources are loaded before initializing
46
+ $(window).on('load', function() {
47
+ if (!appInitialized) {
48
+ appInitialized = true;
49
+ initializeApp();
50
+ }
51
+ });
52
+
53
+ // Fallback if window load event doesn't fire
54
+ setTimeout(function() {
55
+ if (!appInitialized) {
56
+ appInitialized = true;
57
+ initializeApp();
58
+ }
59
+ }, 100);
60
+ });
61
+
62
+ function initializeApp() {
63
+ replaceQuickstartSolverForgeAutoHeaderFooter();
64
+
65
+ $("#solveButton").click(function () {
66
+ solve();
67
+ });
68
+ $("#stopSolvingButton").click(function () {
69
+ stopSolving();
70
+ });
71
+ $("#analyzeButton").click(function () {
72
+ analyze();
73
+ });
74
+ $("#byRoomTab").click(function () {
75
+ viewType = "R";
76
+ byRoomTimeline.redraw();
77
+ refreshSchedule();
78
+ });
79
+ $("#byPersonTab").click(function () {
80
+ viewType = "P";
81
+ byPersonTimeline.redraw();
82
+ refreshSchedule();
83
+ });
84
+ setupAjax();
85
+ loadDemoDataDropdown();
86
+ refreshSchedule();
87
+ }
88
+
89
+
90
+ function loadDemoDataDropdown() {
91
+ $.getJSON("/demo-data", function (demoDataList) {
92
+ const dropdown = $("#testDataButton");
93
+ dropdown.empty();
94
+
95
+ demoDataList.forEach(function (name) {
96
+ const isSelected = name === selectedDemoData;
97
+ const item = $(`<a class="dropdown-item" href="#"></a>`)
98
+ .text(name)
99
+ .css("font-weight", isSelected ? "bold" : "normal")
100
+ .click(function (e) {
101
+ e.preventDefault();
102
+ selectDemoData(name);
103
+ });
104
+ dropdown.append(item);
105
+ });
106
+ });
107
+ }
108
+
109
+
110
+ function selectDemoData(name) {
111
+ selectedDemoData = name;
112
+ scheduleId = null; // Reset solver job
113
+ loadDemoDataDropdown(); // Refresh dropdown to show selection
114
+ refreshSchedule();
115
+ }
116
+
117
+
118
+ function setupAjax() {
119
+ $.ajaxSetup({
120
+ headers: {
121
+ 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
122
+ }
123
+ });
124
+
125
+ // Extend jQuery to support $.put() and $.delete()
126
+ jQuery.each(["put", "delete"], function (i, method) {
127
+ jQuery[method] = function (url, data, callback, type) {
128
+ if (jQuery.isFunction(data)) {
129
+ type = type || callback;
130
+ callback = data;
131
+ data = undefined;
132
+ }
133
+ return jQuery.ajax({
134
+ url: url, type: method, dataType: type, data: data, success: callback
135
+ });
136
+ };
137
+ });
138
+ }
139
+
140
+
141
+ function refreshSchedule() {
142
+ let path;
143
+ if (scheduleId === null) {
144
+ path = "/demo-data/" + selectedDemoData;
145
+ } else {
146
+ path = "/schedules/" + scheduleId;
147
+ }
148
+
149
+ $.getJSON(path, function (schedule) {
150
+ loadedSchedule = schedule;
151
+ $('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule));
152
+ renderSchedule(schedule);
153
+ })
154
+ .fail(function (xhr, ajaxOptions, thrownError) {
155
+ showError("Getting the schedule has failed.", xhr);
156
+ refreshSolvingButtons(false);
157
+ });
158
+ }
159
+
160
+
161
+ function renderSchedule(schedule) {
162
+ refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
163
+ $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
164
+
165
+ // Fetch constraint analysis from solver if we have a score
166
+ if (schedule.score) {
167
+ fetchConstraintAnalysis(schedule);
168
+ } else {
169
+ // No score yet - clear cache and render with no violations
170
+ analyzeCache = null;
171
+ renderViews(schedule);
172
+ }
173
+ }
174
+
175
+
176
+ function renderViews(schedule) {
177
+ if (viewType === "R") {
178
+ renderScheduleByRoom(schedule);
179
+ }
180
+ if (viewType === "P") {
181
+ renderScheduleByPerson(schedule);
182
+ }
183
+ }
184
+
185
+
186
+ function fetchConstraintAnalysis(schedule) {
187
+ $.ajax({
188
+ url: "/schedules/analyze",
189
+ type: "PUT",
190
+ data: JSON.stringify(schedule),
191
+ contentType: "application/json",
192
+ success: function(response) {
193
+ // Build mapping: assignmentId -> { hard: [], medium: [], soft: [] }
194
+ analyzeCache = new Map();
195
+
196
+ for (const constraint of response.constraints) {
197
+ const type = getConstraintType(constraint.weight);
198
+
199
+ for (const match of constraint.matches) {
200
+ const assignmentIds = extractAssignmentIds(match.justification, loadedSchedule);
201
+
202
+ for (const id of assignmentIds) {
203
+ if (!analyzeCache.has(id)) {
204
+ analyzeCache.set(id, { hard: [], medium: [], soft: [] });
205
+ }
206
+ analyzeCache.get(id)[type].push({
207
+ constraint: constraint.name,
208
+ score: match.score
209
+ });
210
+ }
211
+ }
212
+ }
213
+
214
+ // Re-render with new analysis data
215
+ renderViews(loadedSchedule);
216
+ },
217
+ error: function(xhr, status, error) {
218
+ console.warn("Failed to fetch constraint analysis:", error);
219
+ analyzeCache = null;
220
+ renderViews(schedule);
221
+ }
222
+ });
223
+ }
224
+
225
+
226
+ function getConstraintType(weight) {
227
+ // Weight format: "0hard/0medium/-1soft" or "1hard/0medium/0soft"
228
+ // Extract the non-zero component
229
+ const hardMatch = weight.match(/(-?\d+)hard/);
230
+ const mediumMatch = weight.match(/(-?\d+)medium/);
231
+ const softMatch = weight.match(/(-?\d+)soft/);
232
+
233
+ if (hardMatch && parseInt(hardMatch[1], 10) !== 0) return 'hard';
234
+ if (mediumMatch && parseInt(mediumMatch[1], 10) !== 0) return 'medium';
235
+ if (softMatch && parseInt(softMatch[1], 10) !== 0) return 'soft';
236
+
237
+ return 'soft'; // Default
238
+ }
239
+
240
+
241
+ function extractAssignmentIds(justification, schedule) {
242
+ const ids = new Set();
243
+ if (!justification?.facts) return [...ids];
244
+
245
+ // Build meeting-to-assignment lookup
246
+ const meetingToAssignment = new Map();
247
+ if (schedule?.meetingAssignments) {
248
+ for (const a of schedule.meetingAssignments) {
249
+ const meetingId = typeof a.meeting === 'object' ? a.meeting.id : a.meeting;
250
+ meetingToAssignment.set(meetingId, a.id);
251
+ }
252
+ }
253
+
254
+ for (const fact of justification.facts) {
255
+ if (fact.type === 'assignment' && fact.id) {
256
+ ids.add(fact.id);
257
+ } else if (fact.type === 'attendance' && fact.meetingId) {
258
+ const assignmentId = meetingToAssignment.get(fact.meetingId);
259
+ if (assignmentId) ids.add(assignmentId);
260
+ }
261
+ }
262
+ return [...ids];
263
+ }
264
+
265
+
266
+ function getConflictStatus(assignmentId) {
267
+ // Use solver's constraint analysis if available (per-assignment violations)
268
+ if (analyzeCache && analyzeCache.has(assignmentId)) {
269
+ const violations = analyzeCache.get(assignmentId);
270
+
271
+ if (violations.hard.length > 0) {
272
+ return {
273
+ status: 'hard',
274
+ icon: '<span class="fas fa-exclamation-triangle text-danger me-1"></span>',
275
+ style: 'background-color: #fee2e2; border-left: 4px solid #dc3545;',
276
+ reason: violations.hard.map(v => v.constraint).join(', ')
277
+ };
278
+ }
279
+
280
+ if (violations.medium.length > 0) {
281
+ return {
282
+ status: 'medium',
283
+ icon: '<span class="fas fa-exclamation-circle text-warning me-1"></span>',
284
+ style: 'background-color: #fef3c7; border-left: 4px solid #ffc107;',
285
+ reason: violations.medium.map(v => v.constraint).join(', ')
286
+ };
287
+ }
288
+
289
+ // Don't show soft violations in timeline - they're optimization trade-offs
290
+ // (e.g., "Overlapping meetings" fires for ANY parallel meetings, even in different rooms)
291
+ // Users can see soft violations in the "Analyze" modal instead
292
+ }
293
+
294
+ // No hard/medium violations - show green (feasible solution)
295
+ return {
296
+ status: 'ok',
297
+ icon: '<span class="fas fa-check-circle text-success me-1"></span>',
298
+ style: 'background-color: #d1fae5; border-left: 4px solid #10b981;',
299
+ reason: ''
300
+ };
301
+ }
302
+
303
+
304
+ function calculatePersonWorkload(schedule) {
305
+ const personMeetingCount = new Map();
306
+
307
+ if (!schedule.meetingAssignments || !schedule.meetings) {
308
+ return personMeetingCount;
309
+ }
310
+
311
+ const meetingMap = new Map();
312
+ schedule.meetings.forEach(m => meetingMap.set(m.id, m));
313
+
314
+ // Count meetings per person (assigned meetings only)
315
+ schedule.meetingAssignments.forEach(assignment => {
316
+ if (assignment.room == null || assignment.startingTimeGrain == null) return;
317
+
318
+ const meeting = typeof assignment.meeting === 'string'
319
+ ? meetingMap.get(assignment.meeting)
320
+ : assignment.meeting;
321
+ if (!meeting) return;
322
+
323
+ // Count required attendees
324
+ (meeting.requiredAttendances || []).forEach(att => {
325
+ const personId = att.person?.id || att.person;
326
+ personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1);
327
+ });
328
+
329
+ // Count preferred attendees
330
+ (meeting.preferredAttendances || []).forEach(att => {
331
+ const personId = att.person?.id || att.person;
332
+ personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1);
333
+ });
334
+ });
335
+
336
+ return personMeetingCount;
337
+ }
338
+
339
+
340
+ function getWorkloadBadge(meetingCount) {
341
+ if (meetingCount === 0) {
342
+ return '<span class="badge bg-secondary ms-2" title="No meetings">0</span>';
343
+ } else if (meetingCount <= 5) {
344
+ return `<span class="badge bg-primary ms-2" title="${meetingCount} meetings">${meetingCount}</span>`;
345
+ } else if (meetingCount <= 9) {
346
+ return `<span class="badge bg-info text-dark ms-2" title="${meetingCount} meetings">${meetingCount}</span>`;
347
+ } else {
348
+ return `<span class="badge bg-dark ms-2" title="${meetingCount} meetings - Heavy workload">${meetingCount}</span>`;
349
+ }
350
+ }
351
+
352
+
353
+ function analyzeUnassignedReason(meeting, schedule) {
354
+ const reasons = [];
355
+
356
+ const totalAttendees = (meeting.requiredAttendances?.length || 0) + (meeting.preferredAttendances?.length || 0);
357
+
358
+ // Check room capacity
359
+ const largestRoomCapacity = Math.max(...schedule.rooms.map(r => r.capacity));
360
+ if (totalAttendees > largestRoomCapacity) {
361
+ reasons.push(`Needs ${totalAttendees} capacity, largest room has ${largestRoomCapacity}`);
362
+ }
363
+
364
+ // Check if meeting duration is very long
365
+ const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60;
366
+ if (durationHours > 3) {
367
+ reasons.push(`Long meeting (${durationHours}h) - fewer available slots`);
368
+ }
369
+
370
+ // Check if required attendees are heavily booked
371
+ const meetingMap = new Map();
372
+ schedule.meetings.forEach(m => meetingMap.set(m.id, m));
373
+
374
+ const requiredAttendeeIds = new Set((meeting.requiredAttendances || []).map(a => a.person?.id || a.person));
375
+ let busyAttendeesCount = 0;
376
+
377
+ schedule.meetingAssignments.forEach(assignment => {
378
+ if (assignment.room == null || assignment.startingTimeGrain == null) return;
379
+ if (assignment.meeting === meeting.id) return;
380
+
381
+ const otherMeeting = typeof assignment.meeting === 'string'
382
+ ? meetingMap.get(assignment.meeting)
383
+ : assignment.meeting;
384
+ if (!otherMeeting) return;
385
+
386
+ const otherRequiredIds = new Set((otherMeeting.requiredAttendances || []).map(a => a.person?.id || a.person));
387
+ for (const id of requiredAttendeeIds) {
388
+ if (otherRequiredIds.has(id)) {
389
+ busyAttendeesCount++;
390
+ break;
391
+ }
392
+ }
393
+ });
394
+
395
+ if (busyAttendeesCount > 0 && requiredAttendeeIds.size > 0) {
396
+ const percentBusy = Math.round((busyAttendeesCount / schedule.meetingAssignments.filter(a => a.room && a.startingTimeGrain).length) * 100);
397
+ if (percentBusy > 30) {
398
+ reasons.push(`Required attendees have many existing meetings`);
399
+ }
400
+ }
401
+
402
+ // If still solving, add generic reason
403
+ if (reasons.length === 0) {
404
+ reasons.push(`Being optimized by solver`);
405
+ }
406
+
407
+ return reasons;
408
+ }
409
+
410
+
411
+ function renderScheduleByRoom(schedule) {
412
+ const unassigned = $("#unassigned");
413
+ unassigned.children().remove();
414
+ byRoomGroupData.clear();
415
+ byRoomItemData.clear();
416
+
417
+ // Check if schedule.rooms exists and is an array
418
+ if (!schedule.rooms || !Array.isArray(schedule.rooms)) {
419
+ console.warn('schedule.rooms is not available or not an array:', schedule.rooms);
420
+ return;
421
+ }
422
+
423
+ $.each(schedule.rooms.sort((e1, e2) => e1.name.localeCompare(e2.name)), (_, room) => {
424
+ let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${room.name}</h5></div>`;
425
+ byRoomGroupData.add({
426
+ id: room.id,
427
+ content: content,
428
+ });
429
+ });
430
+
431
+ const meetingMap = new Map();
432
+ if (schedule.meetings && Array.isArray(schedule.meetings)) {
433
+ schedule.meetings.forEach(m => meetingMap.set(m.id, m));
434
+ }
435
+ const timeGrainMap = new Map();
436
+ if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) {
437
+ schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
438
+ }
439
+ const roomMap = new Map();
440
+ if (schedule.rooms && Array.isArray(schedule.rooms)) {
441
+ schedule.rooms.forEach(r => roomMap.set(r.id, r));
442
+ }
443
+
444
+ if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) {
445
+ console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments);
446
+ return;
447
+ }
448
+
449
+ $.each(schedule.meetingAssignments, (_, assignment) => {
450
+ // Handle both string ID and full object for meeting reference
451
+ const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
452
+ // Handle both string ID and full object for room reference
453
+ const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
454
+ // Handle both string ID and full object for timeGrain reference
455
+ const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
456
+
457
+ // Skip if meeting is not found
458
+ if (!meet) {
459
+ console.warn(`Meeting not found for assignment ${assignment.id}`);
460
+ return;
461
+ }
462
+
463
+ if (room == null || timeGrain == null) {
464
+ const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60;
465
+ const requiredCount = meet.requiredAttendances?.length || 0;
466
+ const preferredCount = meet.preferredAttendances?.length || 0;
467
+ const totalAttendees = requiredCount + preferredCount;
468
+
469
+ // Analyze why unassigned
470
+ const reasons = analyzeUnassignedReason(meet, schedule);
471
+
472
+ const unassignedElement = $(`<div class="card-body"/>`)
473
+ .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))
474
+ .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`))
475
+ .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`));
476
+
477
+ if (reasons.length > 0) {
478
+ const reasonsList = $(`<div class="mt-2 small"/>`);
479
+ reasonsList.append($(`<span class="text-muted">Possible issues:</span>`));
480
+ reasons.forEach(reason => {
481
+ reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`));
482
+ });
483
+ unassignedElement.append(reasonsList);
484
+ }
485
+
486
+ unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement)));
487
+ } else {
488
+ const conflictStatus = getConflictStatus(assignment.id);
489
+ const byRoomElement = $("<div />")
490
+ .append($("<div class='d-flex justify-content-center align-items-center' />")
491
+ .append($(conflictStatus.icon))
492
+ .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
493
+ const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
494
+ const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
495
+ .plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
496
+ const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
497
+ const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15);
498
+ byRoomItemData.add({
499
+ id: assignment.id,
500
+ group: typeof room === 'string' ? room : room.id,
501
+ content: byRoomElement.html(),
502
+ start: startDateTime.toString(),
503
+ end: endDateTime.toString(),
504
+ style: `min-height: 50px; ${conflictStatus.style}`,
505
+ title: conflictStatus.reason || undefined
506
+ });
507
+ }
508
+ });
509
+
510
+ byRoomTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
511
+ JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
512
+ }
513
+
514
+
515
+ function renderScheduleByPerson(schedule) {
516
+ const unassigned = $("#unassigned");
517
+ unassigned.children().remove();
518
+ byPersonGroupData.clear();
519
+ byPersonItemData.clear();
520
+
521
+ // Check if schedule.people exists and is an array
522
+ if (!schedule.people || !Array.isArray(schedule.people)) {
523
+ console.warn('schedule.people is not available or not an array:', schedule.people);
524
+ return;
525
+ }
526
+
527
+ // Calculate meeting count per person for workload indicators
528
+ const personMeetingCount = calculatePersonWorkload(schedule);
529
+
530
+ $.each(schedule.people.sort((e1, e2) => e1.fullName.localeCompare(e2.fullName)), (_, person) => {
531
+ const meetingCount = personMeetingCount.get(person.id) || 0;
532
+ const workloadBadge = getWorkloadBadge(meetingCount);
533
+ let content = `<div class="d-flex flex-column">
534
+ <div class="d-flex align-items-center">
535
+ <h5 class="card-title mb-1">${person.fullName}</h5>
536
+ ${workloadBadge}
537
+ </div>
538
+ </div>`;
539
+ byPersonGroupData.add({
540
+ id: person.id,
541
+ content: content,
542
+ });
543
+ });
544
+ const meetingMap = new Map();
545
+ if (schedule.meetings && Array.isArray(schedule.meetings)) {
546
+ schedule.meetings.forEach(m => meetingMap.set(m.id, m));
547
+ }
548
+ const timeGrainMap = new Map();
549
+ if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) {
550
+ schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
551
+ }
552
+ const roomMap = new Map();
553
+ if (schedule.rooms && Array.isArray(schedule.rooms)) {
554
+ schedule.rooms.forEach(r => roomMap.set(r.id, r));
555
+ }
556
+
557
+ if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) {
558
+ console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments);
559
+ return;
560
+ }
561
+
562
+ $.each(schedule.meetingAssignments, (_, assignment) => {
563
+ // Handle both string ID and full object for meeting reference
564
+ const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
565
+ // Handle both string ID and full object for room reference
566
+ const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
567
+ // Handle both string ID and full object for timeGrain reference
568
+ const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
569
+
570
+ // Skip if meeting is not found
571
+ if (!meet) {
572
+ console.warn(`Meeting not found for assignment ${assignment.id}`);
573
+ return;
574
+ }
575
+
576
+ if (room == null || timeGrain == null) {
577
+ const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60;
578
+ const requiredCount = meet.requiredAttendances?.length || 0;
579
+ const preferredCount = meet.preferredAttendances?.length || 0;
580
+ const totalAttendees = requiredCount + preferredCount;
581
+
582
+ // Analyze why unassigned
583
+ const reasons = analyzeUnassignedReason(meet, schedule);
584
+
585
+ const unassignedElement = $(`<div class="card-body"/>`)
586
+ .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))
587
+ .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`))
588
+ .append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`));
589
+
590
+ if (reasons.length > 0) {
591
+ const reasonsList = $(`<div class="mt-2 small"/>`);
592
+ reasonsList.append($(`<span class="text-muted">Possible issues:</span>`));
593
+ reasons.forEach(reason => {
594
+ reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`));
595
+ });
596
+ unassignedElement.append(reasonsList);
597
+ }
598
+
599
+ unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement)));
600
+ } else {
601
+ const conflictStatus = getConflictStatus(assignment.id);
602
+ const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
603
+ const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
604
+ .plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
605
+ const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
606
+ const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15);
607
+ meet.requiredAttendances.forEach(attendance => {
608
+ const byPersonElement = $("<div />")
609
+ .append($("<div class='d-flex justify-content-center align-items-center' />")
610
+ .append($(conflictStatus.icon))
611
+ .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
612
+ byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-success m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Required")));
613
+ if (meet.preferredAttendances.map(a => a.person).indexOf(attendance.person) >= 0) {
614
+ byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred")));
615
+ }
616
+ byPersonItemData.add({
617
+ id: `${assignment.id}-${attendance.person.id}`,
618
+ group: attendance.person.id,
619
+ content: byPersonElement.html(),
620
+ start: startDateTime.toString(),
621
+ end: endDateTime.toString(),
622
+ style: `min-height: 50px; ${conflictStatus.style}`,
623
+ title: conflictStatus.reason || undefined
624
+ });
625
+ });
626
+ meet.preferredAttendances.forEach(attendance => {
627
+ if (meet.requiredAttendances.map(a => a.person).indexOf(attendance.person) === -1) {
628
+ const byPersonElement = $("<div />")
629
+ .append($("<div class='d-flex justify-content-center align-items-center' />")
630
+ .append($(conflictStatus.icon))
631
+ .append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
632
+ byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred")));
633
+ byPersonItemData.add({
634
+ id: `${assignment.id}-${attendance.person.id}`,
635
+ group: attendance.person.id,
636
+ content: byPersonElement.html(),
637
+ start: startDateTime.toString(),
638
+ end: endDateTime.toString(),
639
+ style: `min-height: 50px; ${conflictStatus.style}`,
640
+ title: conflictStatus.reason || undefined
641
+ });
642
+ }
643
+ });
644
+ }
645
+ });
646
+
647
+ byPersonTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
648
+ JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
649
+ }
650
+
651
+
652
+ // Click handlers for timeline items
653
+ byRoomTimeline.on('select', function (properties) {
654
+ if (properties.items.length > 0) {
655
+ showMeetingDetails(properties.items[0]);
656
+ }
657
+ });
658
+
659
+ byPersonTimeline.on('select', function (properties) {
660
+ if (properties.items.length > 0) {
661
+ // For person view, item id is "assignmentId-personId", extract assignmentId
662
+ const itemId = properties.items[0];
663
+ const assignmentId = itemId.includes('-') ? itemId.split('-').slice(0, -1).join('-') : itemId;
664
+ showMeetingDetails(assignmentId);
665
+ }
666
+ });
667
+
668
+
669
+ function showMeetingDetails(assignmentId) {
670
+ if (!loadedSchedule) return;
671
+
672
+ // Find the assignment
673
+ const assignment = loadedSchedule.meetingAssignments.find(a => a.id === assignmentId);
674
+ if (!assignment) {
675
+ console.warn('Assignment not found:', assignmentId);
676
+ return;
677
+ }
678
+
679
+ // Build lookup maps
680
+ const meetingMap = new Map();
681
+ loadedSchedule.meetings.forEach(m => meetingMap.set(m.id, m));
682
+ const roomMap = new Map();
683
+ loadedSchedule.rooms.forEach(r => roomMap.set(r.id, r));
684
+ const personMap = new Map();
685
+ loadedSchedule.people.forEach(p => personMap.set(p.id, p));
686
+
687
+ // Get meeting and room details
688
+ const meeting = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
689
+ const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
690
+ const timeGrain = typeof assignment.startingTimeGrain === 'string'
691
+ ? loadedSchedule.timeGrains.find(t => t.id === assignment.startingTimeGrain)
692
+ : assignment.startingTimeGrain;
693
+
694
+ if (!meeting) {
695
+ console.warn('Meeting not found for assignment:', assignmentId);
696
+ return;
697
+ }
698
+
699
+ // Get conflict status
700
+ const conflictStatus = getConflictStatus(assignmentId);
701
+
702
+ // Build modal content
703
+ const content = $("#meetingDetailsModalContent");
704
+ content.empty();
705
+
706
+ // Meeting title and status
707
+ const statusBadge = conflictStatus.status === 'hard'
708
+ ? '<span class="badge bg-danger ms-2">Hard Conflict</span>'
709
+ : conflictStatus.status === 'medium'
710
+ ? '<span class="badge bg-warning ms-2">Medium Issue</span>'
711
+ : conflictStatus.status === 'soft'
712
+ ? '<span class="badge bg-info ms-2">Soft Issue</span>'
713
+ : '<span class="badge bg-success ms-2">OK</span>';
714
+
715
+ content.append($('<h4/>').html(meeting.topic + statusBadge));
716
+
717
+ // Show reason if any
718
+ if (conflictStatus.reason) {
719
+ content.append($('<div class="alert alert-info py-2"/>').text(conflictStatus.reason));
720
+ }
721
+
722
+ // Details table
723
+ const detailsTable = $('<table class="table table-sm"/>');
724
+ const tbody = $('<tbody/>');
725
+
726
+ // Duration
727
+ const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60;
728
+ tbody.append($('<tr/>')
729
+ .append($('<th scope="row" style="width: 150px"/>').text('Duration'))
730
+ .append($('<td/>').text(`${durationHours} hour(s) (${(meeting.durationInGrains ?? meeting.duration_in_grains)} time grains)`)));
731
+
732
+ // Room
733
+ if (room) {
734
+ tbody.append($('<tr/>')
735
+ .append($('<th scope="row"/>').text('Room'))
736
+ .append($('<td/>').text(`${room.name} (capacity: ${room.capacity})`)));
737
+ } else {
738
+ tbody.append($('<tr/>')
739
+ .append($('<th scope="row"/>').text('Room'))
740
+ .append($('<td/>').html('<span class="text-danger">Not assigned</span>')));
741
+ }
742
+
743
+ // Time
744
+ if (timeGrain) {
745
+ const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
746
+ const startTime = JSJoda.LocalTime.of(0, 0, 0, 0).plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
747
+ const endTime = startTime.plusMinutes((meeting.durationInGrains ?? meeting.duration_in_grains) * 15);
748
+ tbody.append($('<tr/>')
749
+ .append($('<th scope="row"/>').text('Time'))
750
+ .append($('<td/>').text(`${startDate.toString()} ${startTime.toString()} - ${endTime.toString()}`)));
751
+ } else {
752
+ tbody.append($('<tr/>')
753
+ .append($('<th scope="row"/>').text('Time'))
754
+ .append($('<td/>').html('<span class="text-danger">Not scheduled</span>')));
755
+ }
756
+
757
+ detailsTable.append(tbody);
758
+ content.append(detailsTable);
759
+
760
+ // Required Attendees section
761
+ content.append($('<h5 class="mt-3"/>').text('Required Attendees'));
762
+ if (meeting.requiredAttendances && meeting.requiredAttendances.length > 0) {
763
+ const reqList = $('<ul class="list-group list-group-flush"/>');
764
+ meeting.requiredAttendances.forEach(att => {
765
+ const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person;
766
+ reqList.append($('<li class="list-group-item py-1"/>')
767
+ .html(`<span class="fas fa-user me-2 text-success"></span>${person}`));
768
+ });
769
+ content.append(reqList);
770
+ } else {
771
+ content.append($('<p class="text-muted"/>').text('No required attendees'));
772
+ }
773
+
774
+ // Preferred Attendees section
775
+ content.append($('<h5 class="mt-3"/>').text('Preferred Attendees'));
776
+ if (meeting.preferredAttendances && meeting.preferredAttendances.length > 0) {
777
+ const prefList = $('<ul class="list-group list-group-flush"/>');
778
+ meeting.preferredAttendances.forEach(att => {
779
+ const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person;
780
+ prefList.append($('<li class="list-group-item py-1"/>')
781
+ .html(`<span class="fas fa-user me-2 text-info"></span>${person}`));
782
+ });
783
+ content.append(prefList);
784
+ } else {
785
+ content.append($('<p class="text-muted"/>').text('No preferred attendees'));
786
+ }
787
+
788
+ // Conflict details section
789
+ if (conflictStatus.status !== 'ok' && analyzeCache && analyzeCache.has(assignmentId)) {
790
+ content.append($('<h5 class="mt-3 text-danger"/>').text('Conflicts'));
791
+ const conflictList = $('<ul class="list-group list-group-flush"/>');
792
+
793
+ const violations = analyzeCache.get(assignmentId);
794
+
795
+ // Show hard violations
796
+ violations.hard.forEach(v => {
797
+ conflictList.append($('<li class="list-group-item py-1 text-danger"/>')
798
+ .html(`<span class="fas fa-exclamation-triangle me-2"></span>${v.constraint}`));
799
+ });
800
+
801
+ // Show medium violations
802
+ violations.medium.forEach(v => {
803
+ conflictList.append($('<li class="list-group-item py-1 text-warning"/>')
804
+ .html(`<span class="fas fa-exclamation-circle me-2"></span>${v.constraint}`));
805
+ });
806
+
807
+ // Show soft violations
808
+ violations.soft.forEach(v => {
809
+ conflictList.append($('<li class="list-group-item py-1 text-info"/>')
810
+ .html(`<span class="fas fa-info-circle me-2"></span>${v.constraint}`));
811
+ });
812
+
813
+ content.append(conflictList);
814
+ }
815
+
816
+ // Update modal title
817
+ $("#meetingDetailsModalLabel").text("Meeting Details: " + meeting.topic);
818
+
819
+ // Show modal
820
+ new bootstrap.Modal("#meetingDetailsModal").show();
821
+ }
822
+
823
+
824
+ function solve() {
825
+ if (!loadedSchedule) {
826
+ showError("No schedule data loaded. Please wait for the data to load or refresh the page.");
827
+ return;
828
+ }
829
+
830
+ console.log('Sending schedule data for solving:', loadedSchedule);
831
+ $.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
832
+ scheduleId = data;
833
+ refreshSolvingButtons(true);
834
+ }).fail(function (xhr, ajaxOptions, thrownError) {
835
+ showError("Start solving failed.", xhr);
836
+ refreshSolvingButtons(false);
837
+ }, "text");
838
+ }
839
+
840
+
841
+ function analyze() {
842
+ new bootstrap.Modal("#scoreAnalysisModal").show()
843
+ const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
844
+ scoreAnalysisModalContent.children().remove();
845
+ if (loadedSchedule.score == null) {
846
+ scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
847
+ } else {
848
+ $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
849
+ $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
850
+ let constraints = scoreAnalysis.constraints;
851
+ constraints.sort((a, b) => {
852
+ let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
853
+ if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
854
+ if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
855
+ if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
856
+ return -1;
857
+ } else {
858
+ if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
859
+ if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
860
+ if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
861
+ return -1;
862
+ } else {
863
+ if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
864
+ if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
865
+
866
+ return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
867
+ }
868
+ }
869
+ });
870
+ constraints.map((e) => {
871
+ let components = getScoreComponents(e.weight);
872
+ e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
873
+ e.weight = components[e.type];
874
+ let scores = getScoreComponents(e.score);
875
+ e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
876
+ });
877
+ scoreAnalysis.constraints = constraints;
878
+
879
+ scoreAnalysisModalContent.children().remove();
880
+ scoreAnalysisModalContent.text("");
881
+
882
+ const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
883
+ const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
884
+ .append($(`<th></th>`))
885
+ .append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
886
+ .append($(`<th>Type</th>`))
887
+ .append($(`<th># Matches</th>`))
888
+ .append($(`<th>Weight</th>`))
889
+ .append($(`<th>Score</th>`))
890
+ .append($(`<th></th>`)));
891
+ analysisTable.append(analysisTHead);
892
+ const analysisTBody = $(`<tbody/>`)
893
+ $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
894
+ let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
895
+ if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
896
+
897
+ let row = $(`<tr/>`);
898
+ row.append($(`<td/>`).html(icon))
899
+ .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
900
+ .append($(`<td/>`).text(constraintAnalysis.type))
901
+ .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
902
+ .append($(`<td/>`).text(constraintAnalysis.weight))
903
+ .append($(`<td/>`).text(constraintAnalysis.implicitScore));
904
+ analysisTBody.append(row);
905
+ row.append($(`<td/>`));
906
+ });
907
+ analysisTable.append(analysisTBody);
908
+ scoreAnalysisModalContent.append(analysisTable);
909
+ }).fail(function (xhr, ajaxOptions, thrownError) {
910
+ showError("Analyze failed.", xhr);
911
+ }, "text");
912
+ }
913
+ }
914
+
915
+
916
+ function getScoreComponents(score) {
917
+ let components = {hard: 0, medium: 0, soft: 0};
918
+
919
+ $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => {
920
+ components[parts[2]] = parseInt(parts[1], 10);
921
+ });
922
+
923
+ return components;
924
+ }
925
+
926
+
927
+ function refreshSolvingButtons(solving) {
928
+ if (solving) {
929
+ $("#solveButton").hide();
930
+ $("#stopSolvingButton").show();
931
+ $("#solvingSpinner").addClass("active");
932
+ if (autoRefreshIntervalId == null) {
933
+ autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
934
+ }
935
+ } else {
936
+ $("#solveButton").show();
937
+ $("#stopSolvingButton").hide();
938
+ $("#solvingSpinner").removeClass("active");
939
+ if (autoRefreshIntervalId != null) {
940
+ clearInterval(autoRefreshIntervalId);
941
+ autoRefreshIntervalId = null;
942
+ }
943
+ }
944
+ }
945
+
946
+ function stopSolving() {
947
+ $.delete("/schedules/" + scheduleId, function () {
948
+ refreshSolvingButtons(false);
949
+ refreshSchedule();
950
+ }).fail(function (xhr, ajaxOptions, thrownError) {
951
+ showError("Stop solving failed.", xhr);
952
+ });
953
+ }
954
+
955
+
956
+ function copyTextToClipboard(id) {
957
+ var text = $("#" + id).text().trim();
958
+
959
+ var dummy = document.createElement("textarea");
960
+ document.body.appendChild(dummy);
961
+ dummy.value = text;
962
+ dummy.select();
963
+ document.execCommand("copy");
964
+ document.body.removeChild(dummy);
965
+ }
966
+
967
+
968
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
969
+ const solverforgeHeader = $("header#solverforge-auto-header");
970
+ if (solverforgeHeader != null) {
971
+ solverforgeHeader.css("background-color", "#ffffff");
972
+ solverforgeHeader.append(
973
+ $(`<div class="container-fluid">
974
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
975
+ <a class="navbar-brand" href="https://www.solverforge.org">
976
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
977
+ </a>
978
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
979
+ <span class="navbar-toggler-icon"></span>
980
+ </button>
981
+ <div class="collapse navbar-collapse" id="navbarNav">
982
+ <ul class="nav nav-pills">
983
+ <li class="nav-item active" id="navUIItem">
984
+ <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
985
+ </li>
986
+ <li class="nav-item" id="navRestItem">
987
+ <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
988
+ </li>
989
+ <li class="nav-item" id="navOpenApiItem">
990
+ <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
991
+ </li>
992
+ </ul>
993
+ </div>
994
+ <div class="ms-auto">
995
+ <div class="dropdown">
996
+ <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
997
+ Data
998
+ </button>
999
+ <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
1000
+ </div>
1001
+ </div>
1002
+ </nav>
1003
+ </div>`));
1004
+ }
1005
+
1006
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
1007
+ if (solverforgeFooter != null) {
1008
+ solverforgeFooter.append(
1009
+ $(`<footer class="bg-black text-white-50">
1010
+ <div class="container">
1011
+ <div class="hstack gap-3 p-4">
1012
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
1013
+ <div class="vr"></div>
1014
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
1015
+ <div class="vr"></div>
1016
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
1017
+ <div class="vr"></div>
1018
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
1019
+ </div>
1020
+ </div>
1021
+ </footer>`));
1022
+ }
1023
+ }
static/index.html ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
5
+ <meta content="width=device-width, initial-scale=1" name="viewport">
6
+ <title>Meeting Scheduling - SolverForge for Python</title>
7
+
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
9
+ integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/>
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/>
12
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
13
+ <style>
14
+ .vis-time-axis .vis-grid.vis-saturday,
15
+ .vis-time-axis .vis-grid.vis-sunday {
16
+ background: #D3D7CFFF;
17
+ }
18
+ .vis-item-content {
19
+ width: 100%;
20
+ }
21
+ #byPersonPanel, #byRoomPanel {
22
+ min-height: 300px;
23
+ }
24
+ </style>
25
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
26
+ </head>
27
+ <body>
28
+ <header id="solverforge-auto-header">
29
+ <!-- Filled in by app.js -->
30
+ </header>
31
+ <div class="tab-content">
32
+ <div id="demo" class="tab-pane fade show active container-fluid">
33
+ <div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
34
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
35
+ </div>
36
+ <h1>Meeting Scheduling Solver</h1>
37
+ <p>Generate the optimal schedule for your meeting scheduling.</p>
38
+
39
+ <div class="mb-2">
40
+ <button id="solveButton" type="button" class="btn btn-success">
41
+ <span class="fas fa-play"></span> Solve
42
+ </button>
43
+ <button id="stopSolvingButton" type="button" class="btn btn-danger">
44
+ <span class="fas fa-stop"></span> Stop solving
45
+ </button>
46
+ <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
47
+ <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
48
+ <span class="fas fa-question"></span>
49
+ </button>
50
+
51
+ <div class="float-end">
52
+ <ul class="nav nav-pills" role="tablist">
53
+ <li class="nav-item" role="presentation">
54
+ <button class="nav-link active" id="byRoomTab" data-bs-toggle="tab"
55
+ data-bs-target="#byRoomPanel" type="button" role="tab" aria-controls="byRoomPanel"
56
+ aria-selected="true">By Room
57
+ </button>
58
+ </li>
59
+ <li class="nav-item" role="presentation">
60
+ <button class="nav-link" id="byPersonTab" data-bs-toggle="tab"
61
+ data-bs-target="#byPersonPanel" type="button" role="tab" aria-controls="byPersonPanel"
62
+ aria-selected="true">By Person
63
+ </button>
64
+ </li>
65
+ </ul>
66
+ </div>
67
+ </div>
68
+ <div class="tab-content">
69
+ <div class="tab-pane fade show active" id="byRoomPanel" role="tabpanel" aria-labelledby="byRoomTab">
70
+ <!-- Timeline will be rendered directly here -->
71
+ </div>
72
+ <div class="tab-pane fade" id="byPersonPanel" role="tabpanel" aria-labelledby="byPersonTab">
73
+ <!-- Timeline will be rendered directly here -->
74
+ </div>
75
+ </div>
76
+
77
+ <h2>Unassigned</h2>
78
+ <div id="unassigned" class="row row-cols-4 g-3 mb-4"></div>
79
+ </div>
80
+
81
+ <div id="rest" class="tab-pane fade container-fluid">
82
+ <h1>REST API Guide</h1>
83
+
84
+ <h2>Meeting Scheduling solver integration via cURL</h2>
85
+
86
+ <h3>1. Download demo data</h3>
87
+ <pre>
88
+ <button class="btn btn-outline-dark btn-sm float-end"
89
+ onclick="copyTextToClipboard('curl1')">Copy</button>
90
+ <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -o sample.json</code>
91
+ </pre>
92
+
93
+ <h3>2. Post the sample data for solving</h3>
94
+ <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
95
+ <pre>
96
+ <button class="btn btn-outline-dark btn-sm float-end"
97
+ onclick="copyTextToClipboard('curl2')">Copy</button>
98
+ <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
99
+ </pre>
100
+
101
+ <h3>3. Get the current status and score</h3>
102
+ <pre>
103
+ <button class="btn btn-outline-dark btn-sm float-end"
104
+ onclick="copyTextToClipboard('curl3')">Copy</button>
105
+ <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
106
+ </pre>
107
+
108
+ <h3>4. Get the complete solution</h3>
109
+ <pre>
110
+ <button class="btn btn-outline-dark btn-sm float-end"
111
+ onclick="copyTextToClipboard('curl4')">Copy</button>
112
+ <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json</code>
113
+ </pre>
114
+
115
+ <h3>5. Fetch the analysis of the solution</h3>
116
+ <pre>
117
+ <button class="btn btn-outline-dark btn-sm float-end"
118
+ onclick="copyTextToClipboard('curl5')">Copy</button>
119
+ <code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
120
+ </pre>
121
+
122
+ <h3>6. Terminate solving early</h3>
123
+ <pre>
124
+ <button class="btn btn-outline-dark btn-sm float-end"
125
+ onclick="copyTextToClipboard('curl6')">Copy</button>
126
+ <code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
127
+ </pre>
128
+ </div>
129
+
130
+ <div id="openapi" class="tab-pane fade container-fluid">
131
+ <h1>REST API Reference</h1>
132
+ <div class="ratio ratio-1x1">
133
+ <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
134
+ <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ <footer id="solverforge-auto-footer"></footer>
139
+
140
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
141
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script>
142
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
143
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
144
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
145
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
146
+ integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
147
+ <script src="/app.js"></script>
148
+
149
+ <!-- Meeting Details Modal -->
150
+ <div class="modal fade" id="meetingDetailsModal" tabindex="-1" aria-labelledby="meetingDetailsModalLabel" aria-hidden="true">
151
+ <div class="modal-dialog modal-lg">
152
+ <div class="modal-content">
153
+ <div class="modal-header">
154
+ <h5 class="modal-title" id="meetingDetailsModalLabel">Meeting Details</h5>
155
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
156
+ </div>
157
+ <div class="modal-body" id="meetingDetailsModalContent">
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Score Analysis Modal -->
164
+ <div class="modal fade" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
165
+ <div class="modal-dialog modal-lg">
166
+ <div class="modal-content">
167
+ <div class="modal-header">
168
+ <h5 class="modal-title" id="scoreAnalysisModalLabel">Score Analysis <span id="scoreAnalysisScoreLabel"></span></h5>
169
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
170
+ </div>
171
+ <div class="modal-body" id="scoreAnalysisModalContent">
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </body>
177
+ </html>
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
static/webjars/timefold/css/timefold-webui.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-violet-1-rgb: #3E00FF;
6
+ --ts-violet-2-rgb: #3423A6;
7
+ --ts-violet-3-rgb: #2E1760;
8
+ --ts-violet-4-rgb: #200F4F;
9
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
10
+ --ts-violet-dark-1-rgb: #b6adfd;
11
+ --ts-violet-dark-2-rgb: #c1bbfd;
12
+ --ts-gray-rgb: #666666;
13
+ --ts-white-rgb: #FFFFFF;
14
+ --ts-light-rgb: #F2F2F2;
15
+ --ts-gray-border: #c5c5c5;
16
+
17
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
18
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
19
+ --bs-link-color: var(--ts-violet-1-rgb);
20
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
21
+
22
+ --bs-navbar-color: var(--ts-white-rgb);
23
+ --bs-navbar-hover-color: var(--ts-white-rgb);
24
+ --bs-nav-link-font-size: 18px;
25
+ --bs-nav-link-font-weight: 400;
26
+ --bs-nav-link-color: var(--ts-white-rgb);
27
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
28
+ }
29
+ .btn {
30
+ --bs-btn-border-radius: 1.5rem;
31
+ }
32
+ .btn-primary {
33
+ --bs-btn-bg: var(--ts-violet-1-rgb);
34
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
35
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
36
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
37
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
39
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
40
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
41
+ }
42
+ .btn-outline-primary {
43
+ --bs-btn-color: var(--ts-violet-1-rgb);
44
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
45
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
46
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
50
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
51
+ }
52
+ .navbar-dark {
53
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
54
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
55
+ --bs-navbar-color: var(--ts-white-rgb);
56
+ --bs-navbar-hover-color: var(--ts-white-rgb);
57
+ }
58
+ .nav-pills {
59
+ --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
60
+ }
static/webjars/timefold/img/timefold-favicon.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg ADDED
static/webjars/timefold/img/timefold-logo-stacked-positive.svg ADDED
static/webjars/timefold/js/timefold-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceTimefoldAutoHeaderFooter() {
2
+ const timefoldHeader = $("header#timefold-auto-header");
3
+ if (timefoldHeader != null) {
4
+ timefoldHeader.addClass("bg-black")
5
+ timefoldHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://timefold.ai">
9
+ <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const timefoldFooter = $("footer#timefold-auto-footer");
15
+ if (timefoldFooter != null) {
16
+ timefoldFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Test package initialization
tests/test_constraints.py ADDED
@@ -0,0 +1,562 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.test import ConstraintVerifier
2
+
3
+ from meeting_scheduling.domain import (
4
+ Meeting,
5
+ MeetingAssignment,
6
+ MeetingSchedule,
7
+ Person,
8
+ PreferredAttendance,
9
+ RequiredAttendance,
10
+ Room,
11
+ TimeGrain,
12
+ )
13
+ from meeting_scheduling.constraints import (
14
+ define_constraints,
15
+ room_conflict,
16
+ avoid_overtime,
17
+ required_attendance_conflict,
18
+ required_room_capacity,
19
+ start_and_end_on_same_day,
20
+ required_and_preferred_attendance_conflict,
21
+ preferred_attendance_conflict,
22
+ room_stability,
23
+ )
24
+
25
+
26
+ DEFAULT_TIME_GRAINS = [
27
+ TimeGrain(
28
+ id=str(i + 1), grain_index=i, day_of_year=1, starting_minute_of_day=480 + i * 15
29
+ )
30
+ for i in range(8)
31
+ ]
32
+
33
+ DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)
34
+ SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)
35
+ LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)
36
+ ROOM_A = Room(id="4", name="Room A", capacity=10)
37
+ ROOM_B = Room(id="5", name="Room B", capacity=10)
38
+
39
+
40
+ constraint_verifier = ConstraintVerifier.build(
41
+ define_constraints, MeetingSchedule, MeetingAssignment
42
+ )
43
+
44
+
45
+ def test_room_conflict_unpenalized():
46
+ """Test that no penalty is applied when meetings in the same room do not overlap."""
47
+ meeting1 = create_meeting(1)
48
+ left_assignment = create_meeting_assignment(
49
+ 0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
50
+ )
51
+
52
+ meeting2 = create_meeting(2)
53
+ right_assignment = create_meeting_assignment(
54
+ 1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM
55
+ )
56
+
57
+ constraint_verifier.verify_that(room_conflict).given(
58
+ left_assignment, right_assignment
59
+ ).penalizes(0)
60
+
61
+
62
+ def test_room_conflict_penalized():
63
+ """Test that a penalty is applied when meetings in the same room overlap."""
64
+ meeting1 = create_meeting(1)
65
+ left_assignment = create_meeting_assignment(
66
+ 0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
67
+ )
68
+
69
+ meeting2 = create_meeting(2)
70
+ right_assignment = create_meeting_assignment(
71
+ 1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
72
+ )
73
+
74
+ constraint_verifier.verify_that(room_conflict).given(
75
+ left_assignment, right_assignment
76
+ ).penalizes_by(2)
77
+
78
+
79
+ def test_avoid_overtime_unpenalized():
80
+ """Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""
81
+ meeting = create_meeting(1)
82
+ meeting_assignment = create_meeting_assignment(
83
+ 0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
84
+ )
85
+
86
+ constraint_verifier.verify_that(avoid_overtime).given(
87
+ meeting_assignment, *DEFAULT_TIME_GRAINS
88
+ ).penalizes(0)
89
+
90
+
91
+ def test_avoid_overtime_penalized():
92
+ """Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""
93
+ meeting = create_meeting(1)
94
+ meeting_assignment = create_meeting_assignment(
95
+ 0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
96
+ )
97
+
98
+ constraint_verifier.verify_that(avoid_overtime).given(
99
+ meeting_assignment
100
+ ).penalizes_by(3)
101
+
102
+
103
+ def test_required_attendance_conflict_unpenalized():
104
+ """Test that no penalty is applied when a person does not have overlapping required meetings."""
105
+ person = create_person(1)
106
+
107
+ left_meeting = create_meeting(1, duration=2)
108
+ required_attendance1 = create_required_attendance(0, person, left_meeting)
109
+
110
+ right_meeting = create_meeting(2, duration=2)
111
+ required_attendance2 = create_required_attendance(1, person, right_meeting)
112
+
113
+ left_assignment = create_meeting_assignment(
114
+ 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
115
+ )
116
+ right_assignment = create_meeting_assignment(
117
+ 1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
118
+ )
119
+
120
+ constraint_verifier.verify_that(required_attendance_conflict).given(
121
+ required_attendance1, required_attendance2, left_assignment, right_assignment
122
+ ).penalizes(0)
123
+
124
+
125
+ def test_required_attendance_conflict_penalized():
126
+ """Test that a penalty is applied when a person has overlapping required meetings."""
127
+ person = create_person(1)
128
+
129
+ left_meeting = create_meeting(1, duration=2)
130
+ required_attendance1 = create_required_attendance(0, person, left_meeting)
131
+
132
+ right_meeting = create_meeting(2, duration=2)
133
+ required_attendance2 = create_required_attendance(1, person, right_meeting)
134
+
135
+ left_assignment = create_meeting_assignment(
136
+ 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
137
+ )
138
+ right_assignment = create_meeting_assignment(
139
+ 1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM
140
+ )
141
+
142
+ constraint_verifier.verify_that(required_attendance_conflict).given(
143
+ required_attendance1, required_attendance2, left_assignment, right_assignment
144
+ ).penalizes_by(1)
145
+
146
+
147
+ def test_required_room_capacity_unpenalized():
148
+ """Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""
149
+ person1 = create_person(1)
150
+ person2 = create_person(2)
151
+
152
+ meeting = create_meeting(1, duration=2)
153
+ create_required_attendance(0, person1, meeting)
154
+ create_preferred_attendance(1, person2, meeting)
155
+
156
+ meeting_assignment = create_meeting_assignment(
157
+ 0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM
158
+ )
159
+
160
+ constraint_verifier.verify_that(required_room_capacity).given(
161
+ meeting_assignment
162
+ ).penalizes(0)
163
+
164
+
165
+ def test_required_room_capacity_penalized():
166
+ """Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""
167
+ person1 = create_person(1)
168
+ person2 = create_person(2)
169
+
170
+ meeting = create_meeting(1, duration=2)
171
+ create_required_attendance(0, person1, meeting)
172
+ create_preferred_attendance(1, person2, meeting)
173
+
174
+ meeting_assignment = create_meeting_assignment(
175
+ 0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM
176
+ )
177
+
178
+ constraint_verifier.verify_that(required_room_capacity).given(
179
+ meeting_assignment
180
+ ).penalizes_by(1)
181
+
182
+
183
+ def test_start_and_end_on_same_day_unpenalized():
184
+ """Test that no penalty is applied when a meeting starts and ends on the same day."""
185
+ # Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)
186
+ start_time_grain = TimeGrain(
187
+ id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
188
+ )
189
+ end_time_grain = TimeGrain(
190
+ id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525
191
+ ) # Same day
192
+
193
+ meeting = create_meeting(1)
194
+ meeting_assignment = create_meeting_assignment(
195
+ 0, meeting, start_time_grain, DEFAULT_ROOM
196
+ )
197
+
198
+ constraint_verifier.verify_that(start_and_end_on_same_day).given(
199
+ meeting_assignment, end_time_grain
200
+ ).penalizes(0)
201
+
202
+
203
+ def test_start_and_end_on_same_day_penalized():
204
+ """Test that a penalty is applied when a meeting starts and ends on different days."""
205
+ # Need custom time grains to test different days (start=day 0, end=day 1)
206
+ start_time_grain = TimeGrain(
207
+ id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
208
+ )
209
+ end_time_grain = TimeGrain(
210
+ id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525
211
+ ) # Different day
212
+
213
+ meeting = create_meeting(1)
214
+ meeting_assignment = create_meeting_assignment(
215
+ 0, meeting, start_time_grain, DEFAULT_ROOM
216
+ )
217
+
218
+ constraint_verifier.verify_that(start_and_end_on_same_day).given(
219
+ meeting_assignment, end_time_grain
220
+ ).penalizes_by(1)
221
+
222
+
223
+ def test_multiple_constraint_violations():
224
+ """Test that multiple constraints can be violated simultaneously."""
225
+ person = create_person(1)
226
+
227
+ left_meeting = create_meeting(1)
228
+ required_attendance1 = create_required_attendance(0, person, left_meeting)
229
+ left_assignment = create_meeting_assignment(
230
+ 0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
231
+ )
232
+
233
+ right_meeting = create_meeting(2)
234
+ required_attendance2 = create_required_attendance(1, person, right_meeting)
235
+ right_assignment = create_meeting_assignment(
236
+ 1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
237
+ )
238
+
239
+ constraint_verifier.verify_that(room_conflict).given(
240
+ left_assignment, right_assignment
241
+ ).penalizes_by(2)
242
+ constraint_verifier.verify_that(required_attendance_conflict).given(
243
+ required_attendance1, required_attendance2, left_assignment, right_assignment
244
+ ).penalizes_by(2)
245
+
246
+
247
+ ### Helper functions ###
248
+
249
+
250
+ def create_meeting(id, topic="Meeting", duration=4):
251
+ """Helper to create a meeting with standard parameters."""
252
+ return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)
253
+
254
+
255
+ def create_meeting_assignment(id, meeting, time_grain, room):
256
+ """Helper to create a meeting assignment."""
257
+ return MeetingAssignment(
258
+ id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room
259
+ )
260
+
261
+
262
+ def create_person(id):
263
+ """Helper to create a person."""
264
+ return Person(id=str(id), full_name=f"Person {id}")
265
+
266
+
267
+ def create_required_attendance(id, person, meeting):
268
+ """Helper to create and link required attendance."""
269
+ attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)
270
+ meeting.required_attendances = [attendance]
271
+ return attendance
272
+
273
+
274
+ def create_preferred_attendance(id, person, meeting):
275
+ """Helper to create and link preferred attendance."""
276
+ attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)
277
+ meeting.preferred_attendances = [attendance]
278
+ return attendance
279
+
280
+
281
+ # ========================================
282
+ # Required and Preferred Attendance Conflict Tests
283
+ # ========================================
284
+
285
+
286
+ def test_required_and_preferred_attendance_conflict_unpenalized():
287
+ """Test no penalty when required and preferred meetings don't overlap."""
288
+ person = create_person(1)
289
+
290
+ # Meeting 1: grain 0-3 (duration=4), person required
291
+ meeting1 = create_meeting(1, duration=4)
292
+ attendance1 = create_required_attendance(0, person, meeting1)
293
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
294
+
295
+ # Meeting 2: grain 4-7 (duration=4), person preferred
296
+ meeting2 = create_meeting(2, duration=4)
297
+ attendance2 = create_preferred_attendance(1, person, meeting2)
298
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
299
+
300
+ constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
301
+ attendance1, attendance2, assignment1, assignment2
302
+ ).penalizes_by(0)
303
+
304
+
305
+ def test_required_and_preferred_attendance_conflict_penalized():
306
+ """Test penalty when person required at one meeting and preferred at overlapping meeting."""
307
+ person = create_person(1)
308
+
309
+ # Meeting 1: grain 0-3 (duration=4), person required
310
+ meeting1 = create_meeting(1, duration=4)
311
+ attendance1 = create_required_attendance(0, person, meeting1)
312
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
313
+
314
+ # Meeting 2: grain 2-5 (duration=4), person preferred, overlaps grains 2-3 (2 grains)
315
+ meeting2 = create_meeting(2, duration=4)
316
+ attendance2 = create_preferred_attendance(1, person, meeting2)
317
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], ROOM_A)
318
+
319
+ # Overlap: grains 2-3 = 2 grains
320
+ constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
321
+ attendance1, attendance2, assignment1, assignment2
322
+ ).penalizes_by(2)
323
+
324
+
325
+ # ========================================
326
+ # Preferred Attendance Conflict Tests
327
+ # ========================================
328
+
329
+
330
+ def test_preferred_attendance_conflict_unpenalized():
331
+ """Test no penalty when preferred attendee has non-overlapping meetings."""
332
+ person = create_person(1)
333
+
334
+ # Meeting 1: grain 0-3 (duration=4), person preferred
335
+ meeting1 = create_meeting(1, duration=4)
336
+ attendance1 = create_preferred_attendance(0, person, meeting1)
337
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
338
+
339
+ # Meeting 2: grain 4-7 (duration=4), person preferred
340
+ meeting2 = create_meeting(2, duration=4)
341
+ attendance2 = create_preferred_attendance(1, person, meeting2)
342
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
343
+
344
+ constraint_verifier.verify_that(preferred_attendance_conflict).given(
345
+ attendance1, attendance2, assignment1, assignment2
346
+ ).penalizes_by(0)
347
+
348
+
349
+ def test_preferred_attendance_conflict_penalized():
350
+ """Test penalty when person preferred at multiple overlapping meetings."""
351
+ person = create_person(1)
352
+
353
+ # Meeting 1: grain 0-3 (duration=4), person preferred
354
+ meeting1 = create_meeting(1, duration=4)
355
+ attendance1 = create_preferred_attendance(0, person, meeting1)
356
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
357
+
358
+ # Meeting 2: grain 1-4 (duration=4), person preferred, overlaps grains 1-3 (3 grains)
359
+ meeting2 = create_meeting(2, duration=4)
360
+ attendance2 = create_preferred_attendance(1, person, meeting2)
361
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[1], ROOM_A)
362
+
363
+ # Overlap: grains 1-3 = 3 grains
364
+ constraint_verifier.verify_that(preferred_attendance_conflict).given(
365
+ attendance1, attendance2, assignment1, assignment2
366
+ ).penalizes_by(3)
367
+
368
+
369
+ # ========================================
370
+ # Room Stability Tests
371
+ # ========================================
372
+
373
+
374
+ def test_room_stability_same_room_no_penalty():
375
+ """
376
+ Test that no penalty is applied when a person attends consecutive
377
+ meetings in the same room (stability is maintained).
378
+ """
379
+ person = create_person(1)
380
+
381
+ # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
382
+ meeting1 = create_meeting(1, duration=2)
383
+ attendance1 = create_required_attendance(0, person, meeting1)
384
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
385
+
386
+ # Meeting 2: time grain 3-4 (duration=2) in ROOM_A (same room, gap of 1)
387
+ meeting2 = create_meeting(2, duration=2)
388
+ attendance2 = create_required_attendance(1, person, meeting2)
389
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_A)
390
+
391
+ # Same room should not penalize
392
+ constraint_verifier.verify_that(room_stability).given(
393
+ attendance1, attendance2, assignment1, assignment2
394
+ ).penalizes(0)
395
+
396
+
397
+ def test_room_stability_different_room_with_required_attendance():
398
+ """
399
+ Test that a penalty is applied when a person with required attendance
400
+ has to change rooms between closely scheduled meetings.
401
+ Weighted penalty: back-to-back switches cost more than switches with gaps.
402
+ """
403
+ person = create_person(1)
404
+
405
+ # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
406
+ left_grain_index = 0
407
+ left_duration = 2
408
+ meeting1 = create_meeting(1, duration=left_duration)
409
+ attendance1 = create_required_attendance(0, person, meeting1)
410
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
411
+
412
+ # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
413
+ right_grain_index = 3
414
+ meeting2 = create_meeting(2, duration=2)
415
+ attendance2 = create_required_attendance(1, person, meeting2)
416
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
417
+
418
+ # Weighted penalty: 3 - gap, where gap = right_grain - left_duration - left_grain
419
+ gap = right_grain_index - left_duration - left_grain_index
420
+ expected_penalty = 3 - gap
421
+
422
+ constraint_verifier.verify_that(room_stability).given(
423
+ attendance1, attendance2, assignment1, assignment2
424
+ ).penalizes_by(expected_penalty)
425
+
426
+
427
+ def test_room_stability_different_room_with_preferred_attendance():
428
+ """
429
+ Test that a penalty is applied when a person with preferred attendance
430
+ has to change rooms between closely scheduled meetings.
431
+ Weighted penalty applies to preferred attendance too.
432
+ """
433
+ person = create_person(1)
434
+
435
+ # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
436
+ left_grain_index = 0
437
+ left_duration = 2
438
+ meeting1 = create_meeting(1, duration=left_duration)
439
+ attendance1 = create_preferred_attendance(0, person, meeting1)
440
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
441
+
442
+ # Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
443
+ right_grain_index = 3
444
+ meeting2 = create_meeting(2, duration=2)
445
+ attendance2 = create_preferred_attendance(1, person, meeting2)
446
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
447
+
448
+ # Weighted penalty: 3 - gap
449
+ gap = right_grain_index - left_duration - left_grain_index
450
+ expected_penalty = 3 - gap
451
+
452
+ constraint_verifier.verify_that(room_stability).given(
453
+ attendance1, attendance2, assignment1, assignment2
454
+ ).penalizes_by(expected_penalty)
455
+
456
+
457
+ def test_room_stability_mixed_attendance_types():
458
+ """
459
+ Test that room stability penalty applies when mixing required and preferred
460
+ attendance types for the same person.
461
+ """
462
+ person = create_person(1)
463
+
464
+ # Meeting 1 with required attendance
465
+ left_grain_index = 0
466
+ left_duration = 2
467
+ meeting1 = create_meeting(1, duration=left_duration)
468
+ required_attendance = create_required_attendance(0, person, meeting1)
469
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
470
+
471
+ # Meeting 2 with preferred attendance
472
+ right_grain_index = 3
473
+ meeting2 = create_meeting(2, duration=2)
474
+ preferred_attendance = create_preferred_attendance(1, person, meeting2)
475
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
476
+
477
+ # Weighted penalty: 3 - gap
478
+ gap = right_grain_index - left_duration - left_grain_index
479
+ expected_penalty = 3 - gap
480
+
481
+ constraint_verifier.verify_that(room_stability).given(
482
+ required_attendance, preferred_attendance, assignment1, assignment2
483
+ ).penalizes_by(expected_penalty)
484
+
485
+
486
+ def test_room_stability_far_apart_meetings_no_penalty():
487
+ """
488
+ Test that no penalty is applied when meetings are far apart in time,
489
+ even if they're in different rooms.
490
+ """
491
+ person = create_person(1)
492
+
493
+ # Meeting 1: time grain 0-1 (duration=2) in ROOM_A
494
+ meeting1 = create_meeting(1, duration=2)
495
+ attendance1 = create_required_attendance(0, person, meeting1)
496
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
497
+
498
+ # Meeting 2: time grain 6-7 (duration=2) in ROOM_B
499
+ # gap = grain_index(6) - duration_in_grains(2) - grain_index(0) = 6 - 2 - 0 = 4 > 2
500
+ meeting2 = create_meeting(2, duration=2)
501
+ attendance2 = create_required_attendance(1, person, meeting2)
502
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[6], ROOM_B)
503
+
504
+ # Far apart meetings should not penalize even with room change
505
+ constraint_verifier.verify_that(room_stability).given(
506
+ attendance1, attendance2, assignment1, assignment2
507
+ ).penalizes(0)
508
+
509
+
510
+ def test_room_stability_different_people_no_penalty():
511
+ """
512
+ Test that no penalty is applied when different people have meetings
513
+ in different rooms (room stability is per-person).
514
+ """
515
+ person1 = create_person(1)
516
+ person2 = create_person(2)
517
+
518
+ # Person 1's meeting in ROOM_A
519
+ meeting1 = create_meeting(1, duration=2)
520
+ attendance1 = create_required_attendance(0, person1, meeting1)
521
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
522
+
523
+ # Person 2's meeting in ROOM_B (different person, should not affect stability)
524
+ meeting2 = create_meeting(2, duration=2)
525
+ attendance2 = create_required_attendance(1, person2, meeting2)
526
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_B)
527
+
528
+ # Different people should not trigger room stability penalty
529
+ constraint_verifier.verify_that(room_stability).given(
530
+ attendance1, attendance2, assignment1, assignment2
531
+ ).penalizes(0)
532
+
533
+
534
+ def test_room_stability_back_to_back_highest_penalty():
535
+ """
536
+ Test that back-to-back room switches (gap=0) incur the highest penalty (3).
537
+ This verifies the weighted penalty gradient: closer switches cost more.
538
+ """
539
+ person = create_person(1)
540
+
541
+ # Meeting 1: grain 0-1 (duration=2) in ROOM_A
542
+ left_grain_index = 0
543
+ left_duration = 2
544
+ meeting1 = create_meeting(1, duration=left_duration)
545
+ attendance1 = create_required_attendance(0, person, meeting1)
546
+ assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
547
+
548
+ # Meeting 2: grain 2-3 (immediately after) in ROOM_B
549
+ right_grain_index = 2 # Starts right after meeting1 ends
550
+ meeting2 = create_meeting(2, duration=2)
551
+ attendance2 = create_required_attendance(1, person, meeting2)
552
+ assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
553
+
554
+ # gap = 2 - 2 - 0 = 0, penalty = 3 - 0 = 3
555
+ gap = right_grain_index - left_duration - left_grain_index
556
+ expected_penalty = 3 - gap
557
+ assert gap == 0, f"Test setup error: expected gap=0, got {gap}"
558
+ assert expected_penalty == 3, f"Test setup error: expected penalty=3, got {expected_penalty}"
559
+
560
+ constraint_verifier.verify_that(room_stability).given(
561
+ attendance1, attendance2, assignment1, assignment2
562
+ ).penalizes_by(expected_penalty)
tests/test_feasible.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from meeting_scheduling.rest_api import app
2
+ from meeting_scheduling.converters import MeetingScheduleModel, model_to_schedule
3
+
4
+ from fastapi.testclient import TestClient
5
+ from time import sleep
6
+ from pytest import fail
7
+ import json
8
+
9
+ client = TestClient(app)
10
+
11
+
12
+ def json_to_meeting_schedule(schedule_json):
13
+ """Convert JSON response to MeetingSchedule domain object with proper score."""
14
+ # Parse JSON to Pydantic model first
15
+ schedule_model = MeetingScheduleModel.model_validate(schedule_json)
16
+
17
+ # Convert to domain model
18
+ schedule = model_to_schedule(schedule_model)
19
+
20
+ return schedule
21
+
22
+
23
+ def test_feasible():
24
+ demo_data_response = client.get("/demo-data")
25
+ assert demo_data_response.status_code == 200
26
+
27
+ job_id_response = client.post("/schedules", json=demo_data_response.json())
28
+ assert job_id_response.status_code == 200
29
+ job_id = job_id_response.text[1:-1]
30
+
31
+ ATTEMPTS = 1_000
32
+ for _ in range(ATTEMPTS):
33
+ sleep(0.1)
34
+ schedule_response = client.get(f"/schedules/{job_id}")
35
+ schedule_json = schedule_response.json()
36
+ schedule = json_to_meeting_schedule(schedule_json)
37
+
38
+ if schedule.score is not None and schedule.score.is_feasible:
39
+ # Additional validation like Java version
40
+ assert all(
41
+ assignment.starting_time_grain is not None
42
+ and assignment.room is not None
43
+ for assignment in schedule.meeting_assignments
44
+ )
45
+
46
+ stop_solving_response = client.delete(f"/schedules/{job_id}")
47
+ assert stop_solving_response.status_code == 200
48
+ return
49
+
50
+ client.delete(f"/schedules/{job_id}")
51
+ fail("solution is not feasible")
52
+
53
+
54
+ def test_analyze():
55
+ demo_data_response = client.get("/demo-data")
56
+ assert demo_data_response.status_code == 200
57
+
58
+ job_id_response = client.post("/schedules", json=demo_data_response.json())
59
+ assert job_id_response.status_code == 200
60
+ job_id = job_id_response.text[1:-1]
61
+
62
+ ATTEMPTS = 1_000
63
+ for _ in range(ATTEMPTS):
64
+ sleep(0.1)
65
+ schedule_response = client.get(f"/schedules/{job_id}")
66
+ schedule_json = schedule_response.json()
67
+ schedule = json_to_meeting_schedule(schedule_json)
68
+
69
+ if schedule.score is not None and schedule.score.is_feasible:
70
+ # Test the analyze endpoint
71
+ analysis_response = client.put("/schedules/analyze", json=schedule_json)
72
+ assert analysis_response.status_code == 200
73
+ analysis = analysis_response.text
74
+ assert analysis is not None
75
+
76
+ # Test with fetchPolicy parameter
77
+ analysis_response_2 = client.put(
78
+ "/schedules/analyze?fetchPolicy=FETCH_SHALLOW", json=schedule_json
79
+ )
80
+ assert analysis_response_2.status_code == 200
81
+ analysis_2 = analysis_response_2.text
82
+ assert analysis_2 is not None
83
+
84
+ client.delete(f"/schedules/{job_id}")
85
+ return
86
+
87
+ client.delete(f"/schedules/{job_id}")
88
+ fail("solution is not feasible for analyze test")
89
+
90
+
91
+ def test_analyze_constraint_scores():
92
+ """Test that the analyze endpoint returns proper constraint scores instead of all zeros."""
93
+ demo_data_response = client.get("/demo-data")
94
+ assert demo_data_response.status_code == 200
95
+
96
+ job_id_response = client.post("/schedules", json=demo_data_response.json())
97
+ assert job_id_response.status_code == 200
98
+ job_id = job_id_response.text[1:-1]
99
+
100
+ ATTEMPTS = 1_000
101
+ for _ in range(ATTEMPTS):
102
+ sleep(0.1)
103
+ schedule_response = client.get(f"/schedules/{job_id}")
104
+ schedule_json = schedule_response.json()
105
+ schedule = json_to_meeting_schedule(schedule_json)
106
+
107
+ if schedule.score is not None and schedule.score.is_feasible:
108
+ # Test the analyze endpoint and verify constraint scores
109
+ analysis_response = client.put("/schedules/analyze", json=schedule_json)
110
+ assert analysis_response.status_code == 200
111
+
112
+ # Parse the analysis response
113
+ analysis_data = json.loads(analysis_response.text)
114
+ constraints = analysis_data.get("constraints", [])
115
+
116
+ # Verify we have constraints
117
+ assert len(constraints) > 0, "Should have at least one constraint"
118
+
119
+ # Check that at least some constraints have non-zero scores
120
+ # (since we have a feasible solution, some soft constraints should be violated)
121
+ non_zero_scores = 0
122
+ for constraint in constraints:
123
+ score_str = constraint.get("score", "")
124
+ if score_str and score_str != "0hard/0medium/0soft":
125
+ non_zero_scores += 1
126
+ print(
127
+ f"Found non-zero constraint score: {constraint.get('name')} = {score_str}"
128
+ )
129
+
130
+ # We should have at least some non-zero scores for soft constraints
131
+ assert non_zero_scores > 0, (
132
+ f"Expected some non-zero constraint scores, but all were zero. Total constraints: {len(constraints)}"
133
+ )
134
+
135
+ print(
136
+ f"✅ Analysis test passed: Found {non_zero_scores} constraints with non-zero scores out of {len(constraints)} total constraints"
137
+ )
138
+
139
+ client.delete(f"/schedules/{job_id}")
140
+ return
141
+
142
+ client.delete(f"/schedules/{job_id}")
143
+ fail("solution is not feasible for analyze constraint scores test")