@dytsou/intern-corner-scheduler 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Round-Table Scheduler by dytsou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # Round-table Scheduler (CP-SAT)
2
+
3
+ Python CLI and web interface using OR-Tools CP-SAT to generate round-table seating across rounds with fixed hosts, balanced tables, and pair-wise constraints.
4
+
5
+ ## Input (stdin)
6
+ - Line 1: `a b c`
7
+ - Line 2: `d` (number of same-once pairs)
8
+ - Next `d` lines: `e_i f_i`
9
+ - Next line: `x` (number of never-together pairs)
10
+ - Next `x` lines: `y_i z_i`
11
+
12
+ Interpretation:
13
+ - `a`: participants 1..a
14
+ - `b`: tables 1..b; participants 1..b are hosts, fixed at their table number each round
15
+ - `c`: number of rounds
16
+ - Same-once pairs: each pair should be seated together in exactly one round if possible
17
+ - Never-together pairs: must never be seated together
18
+
19
+ ## Output (stdout)
20
+ JSON with fields:
21
+ - `participants`, `tables`, `rounds`
22
+ - `table_sizes`: balanced per table
23
+ - `assignments`: list per round, each a list per table of participant IDs
24
+ - `satisfied_same_once_pairs`, `unsatisfied_same_once_pairs`
25
+ - `never_together_violations` (should be empty)
26
+ - `objective_value`, `solver_status`
27
+
28
+ ## Install
29
+ ```bash
30
+ make install
31
+ ```
32
+
33
+ ## Run
34
+ Pipe input into the program:
35
+ ```bash
36
+ # example
37
+ cat <<EOF | make run
38
+ 6 2 3
39
+ 1
40
+ 3 5
41
+ 1
42
+ 4 6
43
+ EOF
44
+ ```
45
+
46
+ Or directly:
47
+ ```bash
48
+ cd python && python3 main.py < input.txt
49
+ ```
50
+
51
+ ## Web Interface
52
+
53
+ A modern React + Vite web interface is available for easier use. The frontend is built with React and deployed to GitHub Pages, and the backend runs in Docker on an Ubuntu workstation.
54
+
55
+ ### Quick Start (Local Development)
56
+
57
+ 1. **Install dependencies:**
58
+ ```bash
59
+ make install
60
+ ```
61
+ This will install both Python backend dependencies and Node.js frontend dependencies.
62
+
63
+ **Note**: This project uses [pnpm](https://pnpm.io/) as the package manager. If you don't have pnpm installed, you can install it with:
64
+ ```bash
65
+ npm install -g pnpm
66
+ ```
67
+ Or follow the [pnpm installation guide](https://pnpm.io/installation).
68
+
69
+ 2. **Configure environment variables (optional):**
70
+ ```bash
71
+ cp .env.example .env
72
+ # Edit .env with your settings if needed
73
+ ```
74
+
75
+ 3. **Start the backend server:**
76
+ ```bash
77
+ make serve-backend
78
+ ```
79
+ The API will be available at `http://localhost:8000` (or the port specified in `.env`)
80
+
81
+ 4. **Start the frontend development server:**
82
+ ```bash
83
+ make serve-frontend
84
+ ```
85
+ The frontend will be available at `http://localhost:5173` and will automatically reload on changes.
86
+
87
+ 5. **Update backend URL (if needed):**
88
+ - Edit `src/config.js` and set `BACKEND_URL` to your backend address
89
+ - For production builds, update the URL before running `make build`
90
+
91
+ ## API Documentation
92
+
93
+ The FastAPI backend provides a REST API for scheduling. When the server is running, interactive API documentation is available at:
94
+ - Swagger UI: `http://localhost:8000/docs`
95
+ - ReDoc: `http://localhost:8000/redoc`
96
+
97
+ ### API Endpoint
98
+
99
+ **POST `/api/schedule`**
100
+
101
+ Generate a schedule based on constraints.
102
+
103
+ **Request Body:**
104
+ ```json
105
+ {
106
+ "participants": 6,
107
+ "tables": 2,
108
+ "rounds": 3,
109
+ "same_once_pairs": [
110
+ {"u": 3, "v": 5}
111
+ ],
112
+ "never_together_pairs": [
113
+ {"u": 4, "v": 6}
114
+ ],
115
+ "time_limit_seconds": 60
116
+ }
117
+ ```
118
+
119
+ **Response:**
120
+ ```json
121
+ {
122
+ "participants": 6,
123
+ "tables": 2,
124
+ "rounds": 3,
125
+ "table_sizes": [3, 3],
126
+ "table_sizes_per_round": [[3, 3], [3, 3], [3, 3]],
127
+ "assignments": [
128
+ [[1, 3, 5], [2, 4, 6]],
129
+ [[1, 4, 5], [2, 3, 6]],
130
+ [[1, 3, 6], [2, 4, 5]]
131
+ ],
132
+ "satisfied_same_once_pairs": [[3, 5]],
133
+ "unsatisfied_same_once_pairs": [],
134
+ "never_together_violations": [],
135
+ "objective_value": 1005,
136
+ "solver_status": "OPTIMAL"
137
+ }
138
+ ```
139
+
140
+ ### Health Check
141
+
142
+ **GET `/health`**
143
+
144
+ Returns the health status of the API.
145
+
146
+ **Response:**
147
+ ```json
148
+ {
149
+ "status": "healthy"
150
+ }
151
+ ```
152
+
153
+ ## Modeling Notes
154
+ - Hosts (1..b) are fixed to their own table every round.
155
+ - Tables are balanced: first `a % b` tables have size `a//b + 1`, others `a//b`.
156
+ - Non-hosts do not sit at the same table in consecutive rounds.
157
+ - Objective maximizes how many same-once pairs are met exactly once; never-together is enforced strictly.
158
+
159
+ ## License
160
+
161
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
162
+
163
+
package/index.html ADDED
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>Round-Table Scheduler</title>
9
+ </head>
10
+
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.jsx"></script>
14
+ </body>
15
+
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@dytsou/intern-corner-scheduler",
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "description": "Round-table Scheduler (CP-SAT) - Python CLI and web interface using OR-Tools CP-SAT to generate round-table seating across rounds with fixed hosts, balanced tables, and pair-wise constraints",
6
+ "author": "dytsou",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/dytsou/intern-corner-scheduler.git"
11
+ },
12
+ "keywords": [
13
+ "scheduler",
14
+ "round-table",
15
+ "cp-sat",
16
+ "or-tools",
17
+ "seating",
18
+ "optimization"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "scripts",
23
+ "index.html",
24
+ "vite.config.js",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dependencies": {
29
+ "react": "^19.2.0",
30
+ "react-dom": "^19.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19.2.6",
34
+ "@types/react-dom": "^19.2.3",
35
+ "@vitejs/plugin-react": "^5.1.1",
36
+ "eslint": "^9.39.1",
37
+ "eslint-plugin-react": "^7.37.5",
38
+ "eslint-plugin-react-hooks": "^7.0.1",
39
+ "eslint-plugin-react-refresh": "^0.4.24",
40
+ "vite": "^7.2.4"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "scripts": {
47
+ "dev": "vite",
48
+ "build": "vite build && node scripts/create-nojekyll.js",
49
+ "preview": "vite preview",
50
+ "lint": "eslint . --ext js,jsx --ignore-pattern 'docs/**' --report-unused-disable-directives --max-warnings 0"
51
+ }
52
+ }
@@ -0,0 +1,8 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ // Create .nojekyll file in docs directory for GitHub Pages
5
+ const nojekyllPath = join(process.cwd(), 'docs', '.nojekyll');
6
+ writeFileSync(nojekyllPath, '');
7
+ console.log('Created .nojekyll file in docs/');
8
+
package/src/App.css ADDED
@@ -0,0 +1,449 @@
1
+ .container {
2
+ max-width: 1200px;
3
+ margin: 0 auto;
4
+ padding: 2rem 1rem;
5
+ }
6
+
7
+ header {
8
+ text-align: center;
9
+ margin-bottom: 2rem;
10
+ }
11
+
12
+ header h1 {
13
+ font-size: 2.5rem;
14
+ font-weight: 700;
15
+ color: var(--text-primary);
16
+ margin-bottom: 0.5rem;
17
+ }
18
+
19
+ .subtitle {
20
+ color: var(--text-secondary);
21
+ font-size: 1.1rem;
22
+ }
23
+
24
+ /* Card Styles */
25
+ .card {
26
+ background: var(--card-bg);
27
+ border-radius: var(--border-radius);
28
+ padding: 2rem;
29
+ margin-bottom: 2rem;
30
+ box-shadow: var(--shadow);
31
+ transition: var(--transition);
32
+ }
33
+
34
+ .card:hover {
35
+ box-shadow: var(--shadow-lg);
36
+ }
37
+
38
+ .card h2 {
39
+ font-size: 1.5rem;
40
+ margin-bottom: 1.5rem;
41
+ color: var(--text-primary);
42
+ border-bottom: 2px solid var(--border-color);
43
+ padding-bottom: 0.5rem;
44
+ }
45
+
46
+ /* Form Styles */
47
+ .form-group {
48
+ margin-bottom: 1.5rem;
49
+ }
50
+
51
+ .form-group label {
52
+ display: block;
53
+ font-weight: 600;
54
+ margin-bottom: 0.5rem;
55
+ color: var(--text-primary);
56
+ }
57
+
58
+ .form-group input[type="number"] {
59
+ width: 100%;
60
+ padding: 0.75rem;
61
+ border: 2px solid var(--border-color);
62
+ border-radius: var(--border-radius);
63
+ font-size: 1rem;
64
+ transition: var(--transition);
65
+ }
66
+
67
+ .form-group input[type="number"]:focus {
68
+ outline: none;
69
+ border-color: var(--primary-color);
70
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
71
+ }
72
+
73
+ .form-group small {
74
+ display: block;
75
+ margin-top: 0.25rem;
76
+ color: var(--text-secondary);
77
+ font-size: 0.875rem;
78
+ }
79
+
80
+ /* Pairs Container */
81
+ .pairs-header {
82
+ display: flex;
83
+ justify-content: space-between;
84
+ align-items: center;
85
+ margin-bottom: 0.5rem;
86
+ }
87
+
88
+ .pairs-container {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 0.75rem;
92
+ margin-top: 0.5rem;
93
+ }
94
+
95
+ .pair-item {
96
+ display: flex;
97
+ gap: 0.5rem;
98
+ align-items: center;
99
+ padding: 0.75rem;
100
+ background: var(--bg-color);
101
+ border-radius: var(--border-radius);
102
+ border: 1px solid var(--border-color);
103
+ }
104
+
105
+ .pair-item input {
106
+ flex: 1;
107
+ padding: 0.5rem;
108
+ border: 1px solid var(--border-color);
109
+ border-radius: var(--border-radius);
110
+ }
111
+
112
+ .pair-item .pair-label {
113
+ font-weight: 500;
114
+ color: var(--text-secondary);
115
+ min-width: 60px;
116
+ }
117
+
118
+ .pair-item button {
119
+ padding: 0.5rem 1rem;
120
+ background: var(--danger-color);
121
+ color: white;
122
+ border: none;
123
+ border-radius: var(--border-radius);
124
+ cursor: pointer;
125
+ transition: var(--transition);
126
+ }
127
+
128
+ .pair-item button:hover {
129
+ background: #dc2626;
130
+ }
131
+
132
+ /* Button Styles */
133
+ .btn-primary, .btn-secondary {
134
+ padding: 0.75rem 1.5rem;
135
+ border: none;
136
+ border-radius: var(--border-radius);
137
+ font-size: 1rem;
138
+ font-weight: 600;
139
+ cursor: pointer;
140
+ transition: var(--transition);
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ }
145
+
146
+ .btn-primary {
147
+ background: var(--primary-color);
148
+ color: white;
149
+ width: 100%;
150
+ }
151
+
152
+ .btn-primary:hover:not(:disabled) {
153
+ background: var(--primary-hover);
154
+ }
155
+
156
+ .btn-primary:disabled {
157
+ opacity: 0.6;
158
+ cursor: not-allowed;
159
+ }
160
+
161
+ .btn-secondary {
162
+ background: var(--secondary-color);
163
+ color: white;
164
+ }
165
+
166
+ .btn-secondary:hover {
167
+ background: #475569;
168
+ }
169
+
170
+ /* Statistics Grid */
171
+ .statistics-grid {
172
+ display: grid;
173
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
174
+ gap: 1rem;
175
+ margin-bottom: 2rem;
176
+ }
177
+
178
+ .stat-card {
179
+ background: var(--bg-color);
180
+ padding: 1.5rem;
181
+ border-radius: var(--border-radius);
182
+ border-left: 4px solid var(--primary-color);
183
+ }
184
+
185
+ .stat-card.success {
186
+ border-left-color: var(--success-color);
187
+ }
188
+
189
+ .stat-card.warning {
190
+ border-left-color: var(--warning-color);
191
+ }
192
+
193
+ .stat-card.danger {
194
+ border-left-color: var(--danger-color);
195
+ }
196
+
197
+ .stat-value {
198
+ font-size: 2rem;
199
+ font-weight: 700;
200
+ color: var(--text-primary);
201
+ }
202
+
203
+ .stat-label {
204
+ font-size: 0.875rem;
205
+ color: var(--text-secondary);
206
+ margin-top: 0.25rem;
207
+ }
208
+
209
+ /* Round Selector */
210
+ .round-selector {
211
+ display: flex;
212
+ gap: 0.5rem;
213
+ margin-bottom: 2rem;
214
+ flex-wrap: wrap;
215
+ }
216
+
217
+ .round-btn {
218
+ padding: 0.5rem 1rem;
219
+ border: 2px solid var(--border-color);
220
+ background: var(--card-bg);
221
+ border-radius: var(--border-radius);
222
+ cursor: pointer;
223
+ transition: var(--transition);
224
+ font-weight: 500;
225
+ }
226
+
227
+ .round-btn:hover {
228
+ border-color: var(--primary-color);
229
+ background: var(--bg-color);
230
+ }
231
+
232
+ .round-btn.active {
233
+ background: var(--primary-color);
234
+ color: white;
235
+ border-color: var(--primary-color);
236
+ }
237
+
238
+ /* Table Assignments */
239
+ .assignments-container {
240
+ margin-bottom: 2rem;
241
+ }
242
+
243
+ .round-assignment {
244
+ display: block;
245
+ }
246
+
247
+ .round-title {
248
+ font-size: 1.25rem;
249
+ font-weight: 600;
250
+ margin-bottom: 1rem;
251
+ color: var(--text-primary);
252
+ }
253
+
254
+ .tables-grid {
255
+ display: grid;
256
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
257
+ gap: 1rem;
258
+ }
259
+
260
+ .table-card {
261
+ background: var(--bg-color);
262
+ padding: 1.5rem;
263
+ border-radius: var(--border-radius);
264
+ border: 2px solid var(--border-color);
265
+ transition: var(--transition);
266
+ }
267
+
268
+ .table-card:hover {
269
+ border-color: var(--primary-color);
270
+ box-shadow: var(--shadow);
271
+ }
272
+
273
+ .table-header {
274
+ display: flex;
275
+ justify-content: space-between;
276
+ align-items: center;
277
+ margin-bottom: 1rem;
278
+ padding-bottom: 0.5rem;
279
+ border-bottom: 1px solid var(--border-color);
280
+ }
281
+
282
+ .table-number {
283
+ font-weight: 700;
284
+ font-size: 1.1rem;
285
+ color: var(--text-primary);
286
+ }
287
+
288
+ .table-size {
289
+ font-size: 0.875rem;
290
+ color: var(--text-secondary);
291
+ }
292
+
293
+ .participants-list {
294
+ display: flex;
295
+ flex-wrap: wrap;
296
+ gap: 0.5rem;
297
+ }
298
+
299
+ .participant-badge {
300
+ padding: 0.375rem 0.75rem;
301
+ border-radius: 20px;
302
+ font-weight: 500;
303
+ font-size: 0.875rem;
304
+ }
305
+
306
+ .participant-badge.host {
307
+ background: var(--primary-color);
308
+ color: white;
309
+ }
310
+
311
+ .participant-badge.guest {
312
+ background: var(--secondary-color);
313
+ color: white;
314
+ }
315
+
316
+ /* Pair Status */
317
+ .pair-status-container {
318
+ margin-top: 2rem;
319
+ }
320
+
321
+ .pair-status-section {
322
+ margin-bottom: 1.5rem;
323
+ }
324
+
325
+ .pair-status-section h3 {
326
+ font-size: 1.1rem;
327
+ margin-bottom: 0.75rem;
328
+ color: var(--text-primary);
329
+ }
330
+
331
+ .pair-list {
332
+ display: flex;
333
+ flex-wrap: wrap;
334
+ gap: 0.5rem;
335
+ }
336
+
337
+ .pair-badge {
338
+ padding: 0.5rem 1rem;
339
+ border-radius: var(--border-radius);
340
+ font-weight: 500;
341
+ font-size: 0.875rem;
342
+ }
343
+
344
+ .pair-badge.satisfied {
345
+ background: var(--success-color);
346
+ color: white;
347
+ }
348
+
349
+ .pair-badge.unsatisfied {
350
+ background: var(--warning-color);
351
+ color: white;
352
+ }
353
+
354
+ .pair-badge.violation {
355
+ background: var(--danger-color);
356
+ color: white;
357
+ }
358
+
359
+ /* Error Message */
360
+ .error-message {
361
+ background: #fee2e2;
362
+ border: 1px solid var(--danger-color);
363
+ color: #991b1b;
364
+ padding: 1rem;
365
+ border-radius: var(--border-radius);
366
+ margin-bottom: 1rem;
367
+ position: relative;
368
+ }
369
+
370
+ .error-dismiss {
371
+ position: absolute;
372
+ top: 0.5rem;
373
+ right: 0.5rem;
374
+ background: transparent;
375
+ border: none;
376
+ color: #991b1b;
377
+ font-size: 1.5rem;
378
+ cursor: pointer;
379
+ padding: 0;
380
+ width: 24px;
381
+ height: 24px;
382
+ display: flex;
383
+ align-items: center;
384
+ justify-content: center;
385
+ }
386
+
387
+ .error-dismiss:hover {
388
+ opacity: 0.7;
389
+ }
390
+
391
+ /* Loading State */
392
+ .loading {
393
+ opacity: 0.6;
394
+ pointer-events: none;
395
+ }
396
+
397
+ /* Responsive Design */
398
+ @media (max-width: 768px) {
399
+ .container {
400
+ padding: 1rem;
401
+ }
402
+
403
+ header h1 {
404
+ font-size: 2rem;
405
+ }
406
+
407
+ .card {
408
+ padding: 1.5rem;
409
+ }
410
+
411
+ .statistics-grid {
412
+ grid-template-columns: 1fr;
413
+ }
414
+
415
+ .tables-grid {
416
+ grid-template-columns: 1fr;
417
+ }
418
+
419
+ .pairs-header {
420
+ flex-direction: column;
421
+ align-items: flex-start;
422
+ gap: 0.5rem;
423
+ }
424
+ }
425
+
426
+ /* Footer */
427
+ footer {
428
+ margin-top: 3rem;
429
+ padding: 2rem 0;
430
+ text-align: center;
431
+ color: var(--text-secondary);
432
+ font-size: 0.9rem;
433
+ border-top: 1px solid var(--border-color);
434
+ }
435
+
436
+ footer p {
437
+ margin: 0.5rem 0;
438
+ }
439
+
440
+ footer a {
441
+ color: var(--primary-color);
442
+ text-decoration: none;
443
+ transition: var(--transition);
444
+ }
445
+
446
+ footer a:hover {
447
+ text-decoration: underline;
448
+ }
449
+
package/src/App.jsx ADDED
@@ -0,0 +1,51 @@
1
+ import { useState } from 'react';
2
+ import ScheduleForm from './components/ScheduleForm';
3
+ import ResultsDisplay from './components/ResultsDisplay';
4
+ import ErrorMessage from './components/ErrorMessage';
5
+ import Header from './components/Header';
6
+ import Footer from './components/Footer';
7
+ import './App.css';
8
+
9
+ function App() {
10
+ const [schedule, setSchedule] = useState(null);
11
+ const [error, setError] = useState(null);
12
+ const [loading, setLoading] = useState(false);
13
+
14
+ const handleScheduleSubmit = async (formData) => {
15
+ setError(null);
16
+ setLoading(true);
17
+
18
+ try {
19
+ const { generateSchedule } = await import('./services/api');
20
+ const data = await generateSchedule(formData);
21
+ setSchedule(data);
22
+ } catch (err) {
23
+ setError(`Failed to generate schedule: ${err.message}. Make sure the backend is running and accessible.`);
24
+ } finally {
25
+ setLoading(false);
26
+ }
27
+ };
28
+
29
+ const handleReset = () => {
30
+ setSchedule(null);
31
+ setError(null);
32
+ };
33
+
34
+ return (
35
+ <div className="container">
36
+ <Header />
37
+ <div className="main-content">
38
+ {!schedule ? (
39
+ <ScheduleForm onSubmit={handleScheduleSubmit} loading={loading} />
40
+ ) : (
41
+ <ResultsDisplay schedule={schedule} onReset={handleReset} />
42
+ )}
43
+ {error && <ErrorMessage message={error} onDismiss={() => setError(null)} />}
44
+ </div>
45
+ <Footer />
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export default App;
51
+
@@ -0,0 +1,17 @@
1
+ function ErrorMessage({ message, onDismiss }) {
2
+ if (!message) return null;
3
+
4
+ return (
5
+ <div className="error-message" style={{ display: 'block' }}>
6
+ {message}
7
+ {onDismiss && (
8
+ <button onClick={onDismiss} className="error-dismiss" aria-label="Dismiss error">
9
+ ×
10
+ </button>
11
+ )}
12
+ </div>
13
+ );
14
+ }
15
+
16
+ export default ErrorMessage;
17
+
@@ -0,0 +1,15 @@
1
+ function Footer() {
2
+ return (
3
+ <footer>
4
+ <p>Round-Table Scheduler &copy; 2025 by{' '}
5
+ <a href="https://github.com/dytsou" target="_blank" rel="noopener noreferrer">dytsou</a>
6
+ </p>
7
+ <p>Licensed under the{' '}
8
+ <a href="https://github.com/dytsou/intern-corner-scheduler/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">MIT License</a>
9
+ </p>
10
+ </footer>
11
+ );
12
+ }
13
+
14
+ export default Footer;
15
+
@@ -0,0 +1,11 @@
1
+ function Header() {
2
+ return (
3
+ <header>
4
+ <h1>Round-Table Scheduler</h1>
5
+ <p className="subtitle">Generate optimal seating arrangements with constraints</p>
6
+ </header>
7
+ );
8
+ }
9
+
10
+ export default Header;
11
+
@@ -0,0 +1,40 @@
1
+ function PairInput({ pair, onChange, onRemove }) {
2
+ const handleUChange = (e) => {
3
+ const value = e.target.value;
4
+ onChange('u', value === '' ? '' : parseInt(value) || '');
5
+ };
6
+
7
+ const handleVChange = (e) => {
8
+ const value = e.target.value;
9
+ onChange('v', value === '' ? '' : parseInt(value) || '');
10
+ };
11
+
12
+ return (
13
+ <div className="pair-item">
14
+ <span className="pair-label">Pair:</span>
15
+ <input
16
+ type="number"
17
+ className="pair-u"
18
+ placeholder="Participant 1"
19
+ min="1"
20
+ value={pair.u === '' || pair.u === undefined ? '' : pair.u}
21
+ onChange={handleUChange}
22
+ />
23
+ <span>×</span>
24
+ <input
25
+ type="number"
26
+ className="pair-v"
27
+ placeholder="Participant 2"
28
+ min="1"
29
+ value={pair.v === '' || pair.v === undefined ? '' : pair.v}
30
+ onChange={handleVChange}
31
+ />
32
+ <button type="button" className="remove-pair" onClick={onRemove}>
33
+ Remove
34
+ </button>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export default PairInput;
40
+
@@ -0,0 +1,41 @@
1
+ function PairStatus({ schedule }) {
2
+ const renderPairSection = (title, pairs, className) => {
3
+ if (pairs.length === 0) return null;
4
+
5
+ return (
6
+ <div className="pair-status-section">
7
+ <h3>{title}</h3>
8
+ <div className="pair-list">
9
+ {pairs.map((pair, index) => (
10
+ <span key={index} className={`pair-badge ${className}`}>
11
+ {pair[0]} × {pair[1]}
12
+ </span>
13
+ ))}
14
+ </div>
15
+ </div>
16
+ );
17
+ };
18
+
19
+ return (
20
+ <div className="pair-status-container">
21
+ {renderPairSection(
22
+ 'Satisfied Same-Once Pairs',
23
+ schedule.satisfied_same_once_pairs,
24
+ 'satisfied'
25
+ )}
26
+ {renderPairSection(
27
+ 'Unsatisfied Same-Once Pairs',
28
+ schedule.unsatisfied_same_once_pairs,
29
+ 'unsatisfied'
30
+ )}
31
+ {renderPairSection(
32
+ 'Never-Together Violations',
33
+ schedule.never_together_violations,
34
+ 'violation'
35
+ )}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export default PairStatus;
41
+
@@ -0,0 +1,37 @@
1
+ import { useState } from 'react';
2
+ import Statistics from './Statistics';
3
+ import RoundSelector from './RoundSelector';
4
+ import TableAssignments from './TableAssignments';
5
+ import PairStatus from './PairStatus';
6
+
7
+ function ResultsDisplay({ schedule, onReset }) {
8
+ const [currentRound, setCurrentRound] = useState(0);
9
+
10
+ return (
11
+ <section className="card" id="results-section">
12
+ <h2>Schedule Results</h2>
13
+
14
+ <Statistics schedule={schedule} />
15
+
16
+ <RoundSelector
17
+ numRounds={schedule.rounds}
18
+ currentRound={currentRound}
19
+ onRoundChange={setCurrentRound}
20
+ />
21
+
22
+ <TableAssignments
23
+ schedule={schedule}
24
+ currentRound={currentRound}
25
+ />
26
+
27
+ <PairStatus schedule={schedule} />
28
+
29
+ <button type="button" className="btn-secondary" onClick={onReset}>
30
+ Create New Schedule
31
+ </button>
32
+ </section>
33
+ );
34
+ }
35
+
36
+ export default ResultsDisplay;
37
+
@@ -0,0 +1,19 @@
1
+ function RoundSelector({ numRounds, currentRound, onRoundChange }) {
2
+ return (
3
+ <div className="round-selector">
4
+ {Array.from({ length: numRounds }, (_, i) => (
5
+ <button
6
+ key={i}
7
+ type="button"
8
+ className={`round-btn ${i === currentRound ? 'active' : ''}`}
9
+ onClick={() => onRoundChange(i)}
10
+ >
11
+ Round {i + 1}
12
+ </button>
13
+ ))}
14
+ </div>
15
+ );
16
+ }
17
+
18
+ export default RoundSelector;
19
+
@@ -0,0 +1,178 @@
1
+ import { useState } from 'react';
2
+ import PairInput from './PairInput';
3
+
4
+ function ScheduleForm({ onSubmit, loading }) {
5
+ const [participants, setParticipants] = useState('');
6
+ const [tables, setTables] = useState('');
7
+ const [rounds, setRounds] = useState('');
8
+ const [timeLimit, setTimeLimit] = useState(60);
9
+ const [sameOncePairs, setSameOncePairs] = useState([]);
10
+ const [neverTogetherPairs, setNeverTogetherPairs] = useState([]);
11
+
12
+ const handleSubmit = (e) => {
13
+ e.preventDefault();
14
+
15
+ const participantsNum = parseInt(participants);
16
+ const tablesNum = parseInt(tables);
17
+ const roundsNum = parseInt(rounds);
18
+
19
+ // Validation
20
+ if (tablesNum > participantsNum) {
21
+ alert('Number of tables cannot exceed number of participants');
22
+ return;
23
+ }
24
+
25
+ const formData = {
26
+ participants: participantsNum,
27
+ tables: tablesNum,
28
+ rounds: roundsNum,
29
+ same_once_pairs: sameOncePairs
30
+ .filter(pair => typeof pair.u === 'number' && typeof pair.v === 'number' && pair.u > 0 && pair.v > 0)
31
+ .map(pair => ({ u: pair.u, v: pair.v })),
32
+ never_together_pairs: neverTogetherPairs
33
+ .filter(pair => typeof pair.u === 'number' && typeof pair.v === 'number' && pair.u > 0 && pair.v > 0)
34
+ .map(pair => ({ u: pair.u, v: pair.v })),
35
+ time_limit_seconds: timeLimit,
36
+ };
37
+
38
+ onSubmit(formData);
39
+ };
40
+
41
+ const addSameOncePair = () => {
42
+ setSameOncePairs([...sameOncePairs, { u: '', v: '' }]);
43
+ };
44
+
45
+ const addNeverTogetherPair = () => {
46
+ setNeverTogetherPairs([...neverTogetherPairs, { u: '', v: '' }]);
47
+ };
48
+
49
+ const updateSameOncePair = (index, field, value) => {
50
+ const updated = [...sameOncePairs];
51
+ updated[index] = { ...updated[index], [field]: value };
52
+ setSameOncePairs(updated);
53
+ };
54
+
55
+ const updateNeverTogetherPair = (index, field, value) => {
56
+ const updated = [...neverTogetherPairs];
57
+ updated[index] = { ...updated[index], [field]: value };
58
+ setNeverTogetherPairs(updated);
59
+ };
60
+
61
+ const removeSameOncePair = (index) => {
62
+ setSameOncePairs(sameOncePairs.filter((_, i) => i !== index));
63
+ };
64
+
65
+ const removeNeverTogetherPair = (index) => {
66
+ setNeverTogetherPairs(neverTogetherPairs.filter((_, i) => i !== index));
67
+ };
68
+
69
+ return (
70
+ <section className="card" id="input-section">
71
+ <h2>Schedule Parameters</h2>
72
+ <form id="schedule-form" onSubmit={handleSubmit} className={loading ? 'loading' : ''}>
73
+ <div className="form-group">
74
+ <label htmlFor="participants">Number of Participants</label>
75
+ <input
76
+ type="number"
77
+ id="participants"
78
+ name="participants"
79
+ min="1"
80
+ required
81
+ value={participants}
82
+ onChange={(e) => setParticipants(e.target.value)}
83
+ />
84
+ <small>Total number of participants (1..a)</small>
85
+ </div>
86
+
87
+ <div className="form-group">
88
+ <label htmlFor="tables">Number of Tables</label>
89
+ <input
90
+ type="number"
91
+ id="tables"
92
+ name="tables"
93
+ min="1"
94
+ required
95
+ value={tables}
96
+ onChange={(e) => setTables(e.target.value)}
97
+ />
98
+ <small>Number of tables (1..b). Participants 1..b will be hosts.</small>
99
+ </div>
100
+
101
+ <div className="form-group">
102
+ <label htmlFor="rounds">Number of Rounds</label>
103
+ <input
104
+ type="number"
105
+ id="rounds"
106
+ name="rounds"
107
+ min="1"
108
+ required
109
+ value={rounds}
110
+ onChange={(e) => setRounds(e.target.value)}
111
+ />
112
+ <small>Number of rounds to schedule</small>
113
+ </div>
114
+
115
+ <div className="form-group">
116
+ <label htmlFor="time-limit">Time Limit (seconds)</label>
117
+ <input
118
+ type="number"
119
+ id="time-limit"
120
+ name="time-limit"
121
+ min="1"
122
+ max="300"
123
+ value={timeLimit}
124
+ onChange={(e) => setTimeLimit(parseInt(e.target.value))}
125
+ />
126
+ <small>Maximum time for the solver (1-300 seconds)</small>
127
+ </div>
128
+
129
+ <div className="form-group">
130
+ <div className="pairs-header">
131
+ <label>Same-Once Pairs</label>
132
+ <button type="button" className="btn-secondary" onClick={addSameOncePair}>
133
+ + Add Pair
134
+ </button>
135
+ </div>
136
+ <small>Pairs that should be seated together exactly once</small>
137
+ <div className="pairs-container">
138
+ {sameOncePairs.map((pair, index) => (
139
+ <PairInput
140
+ key={index}
141
+ pair={pair}
142
+ onChange={(field, value) => updateSameOncePair(index, field, value)}
143
+ onRemove={() => removeSameOncePair(index)}
144
+ />
145
+ ))}
146
+ </div>
147
+ </div>
148
+
149
+ <div className="form-group">
150
+ <div className="pairs-header">
151
+ <label>Never-Together Pairs</label>
152
+ <button type="button" className="btn-secondary" onClick={addNeverTogetherPair}>
153
+ + Add Pair
154
+ </button>
155
+ </div>
156
+ <small>Pairs that must never be seated together</small>
157
+ <div className="pairs-container">
158
+ {neverTogetherPairs.map((pair, index) => (
159
+ <PairInput
160
+ key={index}
161
+ pair={pair}
162
+ onChange={(field, value) => updateNeverTogetherPair(index, field, value)}
163
+ onRemove={() => removeNeverTogetherPair(index)}
164
+ />
165
+ ))}
166
+ </div>
167
+ </div>
168
+
169
+ <button type="submit" className="btn-primary" disabled={loading}>
170
+ {loading ? 'Generating...' : 'Generate Schedule'}
171
+ </button>
172
+ </form>
173
+ </section>
174
+ );
175
+ }
176
+
177
+ export default ScheduleForm;
178
+
@@ -0,0 +1,33 @@
1
+ function Statistics({ schedule }) {
2
+ const satisfied = schedule.satisfied_same_once_pairs.length;
3
+ const unsatisfied = schedule.unsatisfied_same_once_pairs.length;
4
+ const violations = schedule.never_together_violations.length;
5
+
6
+ return (
7
+ <div className="statistics-grid">
8
+ <div className="stat-card success">
9
+ <div className="stat-value">{satisfied}</div>
10
+ <div className="stat-label">Satisfied Same-Once Pairs</div>
11
+ </div>
12
+ <div className="stat-card warning">
13
+ <div className="stat-value">{unsatisfied}</div>
14
+ <div className="stat-label">Unsatisfied Same-Once Pairs</div>
15
+ </div>
16
+ <div className={`stat-card ${violations > 0 ? 'danger' : 'success'}`}>
17
+ <div className="stat-value">{violations}</div>
18
+ <div className="stat-label">Never-Together Violations</div>
19
+ </div>
20
+ <div className="stat-card">
21
+ <div className="stat-value">{schedule.objective_value}</div>
22
+ <div className="stat-label">Objective Value</div>
23
+ </div>
24
+ <div className="stat-card">
25
+ <div className="stat-value">{schedule.solver_status}</div>
26
+ <div className="stat-label">Solver Status</div>
27
+ </div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ export default Statistics;
33
+
@@ -0,0 +1,39 @@
1
+ function TableAssignments({ schedule, currentRound }) {
2
+ const roundAssignments = schedule.assignments[currentRound];
3
+
4
+ return (
5
+ <div className="assignments-container">
6
+ <div className="round-assignment active">
7
+ <h3 className="round-title">Round {currentRound + 1}</h3>
8
+ <div className="tables-grid">
9
+ {roundAssignments.map((table, tableIndex) => (
10
+ <div key={tableIndex} className="table-card">
11
+ <div className="table-header">
12
+ <span className="table-number">Table {tableIndex + 1}</span>
13
+ <span className="table-size">
14
+ {table.length} participant{table.length !== 1 ? 's' : ''}
15
+ </span>
16
+ </div>
17
+ <div className="participants-list">
18
+ {table.map((participant) => {
19
+ const isHost = participant <= schedule.tables;
20
+ return (
21
+ <span
22
+ key={participant}
23
+ className={`participant-badge ${isHost ? 'host' : 'guest'}`}
24
+ >
25
+ P{participant}{isHost ? ' (Host)' : ''}
26
+ </span>
27
+ );
28
+ })}
29
+ </div>
30
+ </div>
31
+ ))}
32
+ </div>
33
+ </div>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export default TableAssignments;
39
+
package/src/config.js ADDED
@@ -0,0 +1,20 @@
1
+ // API Configuration
2
+ // Override this in your deployment or set via environment
3
+ export const API_CONFIG = {
4
+ // Default backend URL - update this to point to your backend
5
+ // For local development: "http://localhost:8000"
6
+ // For production: "http://your-ubuntu-workstation-ip:8000" or your domain
7
+ BACKEND_URL: window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
8
+ ? 'http://localhost:8000'
9
+ : 'http://localhost:8000', // Update this with your backend URL
10
+
11
+ // API endpoints
12
+ ENDPOINTS: {
13
+ SCHEDULE: '/api/schedule',
14
+ HEALTH: '/health',
15
+ },
16
+
17
+ // Timeout for API calls (milliseconds)
18
+ TIMEOUT: 120000, // 2 minutes for solver
19
+ };
20
+
package/src/index.css ADDED
@@ -0,0 +1,36 @@
1
+ /* Modern CSS Reset and Base Styles */
2
+ * {
3
+ margin: 0;
4
+ padding: 0;
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ :root {
9
+ --primary-color: #2563eb;
10
+ --primary-hover: #1d4ed8;
11
+ --secondary-color: #64748b;
12
+ --success-color: #10b981;
13
+ --danger-color: #ef4444;
14
+ --warning-color: #f59e0b;
15
+ --bg-color: #f8fafc;
16
+ --card-bg: #ffffff;
17
+ --text-primary: #1e293b;
18
+ --text-secondary: #64748b;
19
+ --border-color: #e2e8f0;
20
+ --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
21
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
22
+ --border-radius: 8px;
23
+ --transition: all 0.2s ease;
24
+ }
25
+
26
+ body {
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
28
+ background-color: var(--bg-color);
29
+ color: var(--text-primary);
30
+ line-height: 1.6;
31
+ }
32
+
33
+ #root {
34
+ min-height: 100vh;
35
+ }
36
+
package/src/main.jsx ADDED
@@ -0,0 +1,11 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
11
+
@@ -0,0 +1,27 @@
1
+ import { API_CONFIG } from '../config';
2
+
3
+ export async function generateSchedule(scheduleData) {
4
+ const response = await fetch(`${API_CONFIG.BACKEND_URL}${API_CONFIG.ENDPOINTS.SCHEDULE}`, {
5
+ method: 'POST',
6
+ headers: {
7
+ 'Content-Type': 'application/json',
8
+ },
9
+ body: JSON.stringify(scheduleData),
10
+ });
11
+
12
+ if (!response.ok) {
13
+ const error = await response.json();
14
+ throw new Error(error.detail || `HTTP error! status: ${response.status}`);
15
+ }
16
+
17
+ return await response.json();
18
+ }
19
+
20
+ export async function checkHealth() {
21
+ const response = await fetch(`${API_CONFIG.BACKEND_URL}${API_CONFIG.ENDPOINTS.HEALTH}`);
22
+ if (!response.ok) {
23
+ throw new Error('Health check failed');
24
+ }
25
+ return await response.json();
26
+ }
27
+
package/vite.config.js ADDED
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ build: {
8
+ outDir: 'docs',
9
+ emptyOutDir: true, // Clean docs folder on build
10
+ rollupOptions: {
11
+ input: {
12
+ main: './index.html',
13
+ },
14
+ },
15
+ },
16
+ base: './', // Use relative paths for GitHub Pages
17
+ server: {
18
+ port: 5173,
19
+ open: true,
20
+ },
21
+ })
22
+