@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 +22 -0
- package/README.md +163 -0
- package/index.html +16 -0
- package/package.json +52 -0
- package/scripts/create-nojekyll.js +8 -0
- package/src/App.css +449 -0
- package/src/App.jsx +51 -0
- package/src/components/ErrorMessage.jsx +17 -0
- package/src/components/Footer.jsx +15 -0
- package/src/components/Header.jsx +11 -0
- package/src/components/PairInput.jsx +40 -0
- package/src/components/PairStatus.jsx +41 -0
- package/src/components/ResultsDisplay.jsx +37 -0
- package/src/components/RoundSelector.jsx +19 -0
- package/src/components/ScheduleForm.jsx +178 -0
- package/src/components/Statistics.jsx +33 -0
- package/src/components/TableAssignments.jsx +39 -0
- package/src/config.js +20 -0
- package/src/index.css +36 -0
- package/src/main.jsx +11 -0
- package/src/services/api.js +27 -0
- package/vite.config.js +22 -0
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 © 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,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,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
|
+
|