Compare commits
10 Commits
f84d812602
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 515b9aa317 | |||
| 770b0e84c9 | |||
| d7fd717c95 | |||
| 756c1cdc47 | |||
| 3ecdc91802 | |||
| 934e2c2fd2 | |||
| ab41e8f264 | |||
| 76d308351a | |||
| a41d1cd469 | |||
| c291328bfa |
19
README.md
19
README.md
@@ -1,7 +1,18 @@
|
|||||||
# Tauri + SvelteKit + TypeScript
|
<h1 align="center">rota</h1>
|
||||||
|
|
||||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
Desktop app for generating monthly schedules for medical residents. The scheduling engine uses a parallel constraint-satisfaction DFS with a least-flexibility-first heuristic.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
### Features
|
||||||
|
- Configurable residents, shift types, and forbidden pairings
|
||||||
|
- Negative shift requests and manual pre-assignments
|
||||||
|
- Fairness-aware workload distribution
|
||||||
|
- Export to `.docx` and `.txt`
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
### Stack
|
||||||
|
- **Frontend**: SvelteKit + TypeScript + Tailwind CSS
|
||||||
|
- **Backend**: Rust (Tauri)
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```bash
|
||||||
|
just --list
|
||||||
|
```
|
||||||
|
|||||||
13
justfile
13
justfile
@@ -14,19 +14,22 @@ lint:
|
|||||||
cd {{tauri_path}} && cargo clippy
|
cd {{tauri_path}} && cargo clippy
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cd {{tauri_path}} && cargo test --lib --release
|
cd {{tauri_path}} && cargo test --lib -- --test-threads=1
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
cd {{tauri_path}} && cargo test --test integration --release
|
cd {{tauri_path}} && cargo test --test integration -- --test-threads=1 --nocapture
|
||||||
|
|
||||||
test-all:
|
test-all:
|
||||||
cd {{tauri_path}} && cargo test --release -- --nocapture
|
cd {{tauri_path}} && cargo test --release
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
cd {{tauri_path}} && cargo bench
|
cd {{tauri_path}} && cargo bench
|
||||||
|
|
||||||
# profile:
|
cov:
|
||||||
# cd {{tauri_path}} && cargo flamegraph
|
cd {{tauri_path}} && cargo llvm-cov run
|
||||||
|
|
||||||
|
mutants:
|
||||||
|
cd {{tauri_path}} && cargo mutants
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
|
|||||||
2
src-tauri/.gitignore
vendored
2
src-tauri/.gitignore
vendored
@@ -8,3 +8,5 @@
|
|||||||
|
|
||||||
# Ignore exported txt/doc files and the log file
|
# Ignore exported txt/doc files and the log file
|
||||||
rota.*
|
rota.*
|
||||||
|
|
||||||
|
mutants.*
|
||||||
181
src-tauri/Cargo.lock
generated
181
src-tauri/Cargo.lock
generated
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_log-sys",
|
"android_log-sys",
|
||||||
"env_filter",
|
"env_filter 0.1.4",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -84,12 +84,56 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "0.6.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.13"
|
version = "1.0.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.100"
|
||||||
@@ -637,6 +681,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -742,7 +792,7 @@ dependencies = [
|
|||||||
"ciborium",
|
"ciborium",
|
||||||
"clap",
|
"clap",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"itertools 0.13.0",
|
"itertools",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"oorandom",
|
"oorandom",
|
||||||
"page_size",
|
"page_size",
|
||||||
@@ -762,7 +812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
|
checksum = "ed943f81ea2faa8dcecbbfa50164acf95d555afec96a27871663b300e387b2e4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cast",
|
"cast",
|
||||||
"itertools 0.13.0",
|
"itertools",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -852,6 +902,22 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctor"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e"
|
||||||
|
dependencies = [
|
||||||
|
"ctor-proc-macro",
|
||||||
|
"dtor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctor-proc-macro"
|
||||||
|
version = "0.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
@@ -1030,6 +1096,21 @@ dependencies = [
|
|||||||
"dtoa",
|
"dtoa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtor"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301"
|
||||||
|
dependencies = [
|
||||||
|
"dtor-proc-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dtor-proc-macro"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dunce"
|
name = "dunce"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -1105,6 +1186,29 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_filter"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"env_filter 1.0.0",
|
||||||
|
"jiff",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2033,6 +2137,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -2042,15 +2152,6 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itertools"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
|
||||||
dependencies = [
|
|
||||||
"either",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -2080,6 +2181,30 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff"
|
||||||
|
version = "0.2.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543"
|
||||||
|
dependencies = [
|
||||||
|
"jiff-static",
|
||||||
|
"log",
|
||||||
|
"portable-atomic",
|
||||||
|
"portable-atomic-util",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiff-static"
|
||||||
|
version = "0.2.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2668,6 +2793,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oorandom"
|
name = "oorandom"
|
||||||
version = "11.1.5"
|
version = "11.1.5"
|
||||||
@@ -3009,6 +3140,21 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic-util"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3448,8 +3594,9 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
|
"ctor 0.6.3",
|
||||||
"docx-rs",
|
"docx-rs",
|
||||||
"itertools 0.14.0",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -4340,7 +4487,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
"cargo_metadata",
|
"cargo_metadata",
|
||||||
"ctor",
|
"ctor 0.2.9",
|
||||||
"dunce",
|
"dunce",
|
||||||
"glob",
|
"glob",
|
||||||
"html5ever",
|
"html5ever",
|
||||||
@@ -4868,6 +5015,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
itertools = "0.14.0"
|
|
||||||
rstest = "0.26.1"
|
rstest = "0.26.1"
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
@@ -35,6 +34,8 @@ thiserror = "2.0.18"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.8.1", features = ["html_reports"] }
|
criterion = { version = "0.8.1", features = ["html_reports"] }
|
||||||
|
env_logger = "0.11"
|
||||||
|
ctor = "0.6.3"
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use chrono::Month;
|
use chrono::Month;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
resident::{Resident, ResidentDTO, ResidentId},
|
resident::{Resident, ResidentDTO, ResidentId},
|
||||||
slot::Day,
|
schedule::ShiftType,
|
||||||
|
slot::{Day, Slot},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH: u8 = 2;
|
const MONTH: u8 = 4;
|
||||||
const YEAR: i32 = 2026;
|
const YEAR: i32 = 2026;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ToxicPair((ResidentId, ResidentId));
|
pub struct ToxicPair((ResidentId, ResidentId));
|
||||||
|
|
||||||
impl ToxicPair {
|
impl ToxicPair {
|
||||||
pub fn new(res_id_1: u8, res_id_2: u8) -> Self {
|
pub fn new(r_id_1: u8, r_id_2: u8) -> Self {
|
||||||
Self((ResidentId(res_id_1), ResidentId(res_id_2)))
|
Self((ResidentId(r_id_1), ResidentId(r_id_2)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn matches(&self, other: &ToxicPair) -> bool {
|
pub fn matches(&self, other: &ToxicPair) -> bool {
|
||||||
@@ -55,9 +58,33 @@ pub struct UserConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserConfig {
|
impl UserConfig {
|
||||||
|
pub fn new(month: u8, year: i32) -> Self {
|
||||||
|
let month = Month::try_from(month).unwrap();
|
||||||
|
let total_days = month.num_days(year).unwrap();
|
||||||
|
let total_slots = compute_total_slots(total_days);
|
||||||
|
let total_holiday_slots =
|
||||||
|
compute_total_holiday_slots(total_days, month.number_from_month(), year, &[]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
holidays: vec![],
|
||||||
|
residents: vec![],
|
||||||
|
toxic_pairs: vec![],
|
||||||
|
total_days,
|
||||||
|
total_slots,
|
||||||
|
total_holiday_slots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_holidays(mut self, holidays: Vec<u8>) -> Self {
|
pub fn with_holidays(mut self, holidays: Vec<u8>) -> Self {
|
||||||
self.holidays = holidays;
|
self.holidays = holidays;
|
||||||
self.total_holiday_slots = self.total_holiday_slots();
|
self.total_holiday_slots = compute_total_holiday_slots(
|
||||||
|
self.total_days,
|
||||||
|
self.month.number_from_month(),
|
||||||
|
self.year,
|
||||||
|
&self.holidays,
|
||||||
|
);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,34 +102,61 @@ impl UserConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn total_holiday_slots(&self) -> u8 {
|
pub fn update_month(&mut self, month: u8) {
|
||||||
(1..=self.total_days)
|
self.month = Month::try_from(month).unwrap();
|
||||||
.filter(|&d| self.is_holiday_or_weekend_slot(d))
|
self.total_days = self.month.num_days(self.year).unwrap();
|
||||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
self.total_slots = compute_total_slots(self.total_days);
|
||||||
.sum()
|
self.total_holiday_slots = compute_total_holiday_slots(
|
||||||
|
self.total_days,
|
||||||
|
self.month.number_from_month(),
|
||||||
|
self.year,
|
||||||
|
&self.holidays,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_holiday_or_weekend_slot(&self, day: u8) -> bool {
|
pub fn is_holiday_or_weekend(&self, day: Day) -> bool {
|
||||||
let day = Day(day);
|
let month = self.month.number_from_month();
|
||||||
day.is_weekend(self.month.number_from_month(), self.year)
|
day.is_weekend(month, self.year) || self.holidays.contains(&(day.0))
|
||||||
|| self.holidays.contains(&(day.0))
|
}
|
||||||
|
|
||||||
|
pub fn is_holiday_or_weekend_slot(&self, slot: Slot) -> bool {
|
||||||
|
self.is_holiday_or_weekend(slot.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_initial_supply(&self) -> HashMap<ShiftType, u8> {
|
||||||
|
let mut supply = HashMap::new();
|
||||||
|
let total_days = self.total_days;
|
||||||
|
|
||||||
|
for d in 1..=total_days {
|
||||||
|
if Day(d).is_open_shift() {
|
||||||
|
*supply.entry(ShiftType::OpenFirst).or_insert(0) += 1;
|
||||||
|
*supply.entry(ShiftType::OpenSecond).or_insert(0) += 1;
|
||||||
|
} else {
|
||||||
|
*supply.entry(ShiftType::Closed).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
supply
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flexibility_map(&self, from: Day) -> HashMap<ResidentId, u16> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for r in &self.residents {
|
||||||
|
let count = (from.0..=self.total_days)
|
||||||
|
.map(|d| r.available_slots_on(Day(d)))
|
||||||
|
.sum();
|
||||||
|
map.insert(r.id, count);
|
||||||
|
}
|
||||||
|
map
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UserConfig {
|
impl Default for UserConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let month = Month::try_from(MONTH).unwrap();
|
let month = Month::try_from(MONTH).unwrap();
|
||||||
|
|
||||||
let total_days = month.num_days(YEAR).unwrap();
|
let total_days = month.num_days(YEAR).unwrap();
|
||||||
|
let total_slots = compute_total_slots(total_days);
|
||||||
let total_slots = (1..=total_days)
|
let total_holiday_slots =
|
||||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
compute_total_holiday_slots(total_days, month.number_from_month(), YEAR, &[]);
|
||||||
.sum();
|
|
||||||
|
|
||||||
let total_holiday_slots = (1..=total_days)
|
|
||||||
.filter(|&d| Day(d).is_weekend(month.number_from_month(), YEAR))
|
|
||||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
month,
|
month,
|
||||||
@@ -122,20 +176,14 @@ impl TryFrom<UserConfigDTO> for UserConfig {
|
|||||||
|
|
||||||
fn try_from(value: UserConfigDTO) -> Result<Self, Self::Error> {
|
fn try_from(value: UserConfigDTO) -> Result<Self, Self::Error> {
|
||||||
let month = Month::try_from(value.month)?;
|
let month = Month::try_from(value.month)?;
|
||||||
|
|
||||||
let total_days = month.num_days(value.year).context("Failed to parse")?;
|
let total_days = month.num_days(value.year).context("Failed to parse")?;
|
||||||
|
let total_slots = compute_total_slots(total_days);
|
||||||
let total_slots = (1..=total_days)
|
let total_holiday_slots = compute_total_holiday_slots(
|
||||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
total_days,
|
||||||
.sum();
|
month.number_from_month(),
|
||||||
|
value.year,
|
||||||
let total_holiday_slots = (1..=total_days)
|
&value.holidays,
|
||||||
.filter(|&d| {
|
);
|
||||||
Day(d).is_weekend(month.number_from_month(), value.year)
|
|
||||||
|| value.holidays.contains(&d)
|
|
||||||
})
|
|
||||||
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
month,
|
month,
|
||||||
@@ -153,3 +201,71 @@ impl TryFrom<UserConfigDTO> for UserConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compute_total_slots(total_days: u8) -> u8 {
|
||||||
|
(1..=total_days)
|
||||||
|
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_total_holiday_slots(total_days: u8, month_num: u32, year: i32, holidays: &[u8]) -> u8 {
|
||||||
|
(1..=total_days)
|
||||||
|
.filter(|&d| Day(d).is_weekend(month_num, year) || holidays.contains(&d))
|
||||||
|
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{
|
||||||
|
config::{ToxicPair, UserConfig},
|
||||||
|
fixtures::complex_config,
|
||||||
|
schedule::ShiftType,
|
||||||
|
slot::{Day, ShiftPosition, Slot},
|
||||||
|
};
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_get_initial_supply(complex_config: UserConfig) {
|
||||||
|
let supply = complex_config.get_initial_supply();
|
||||||
|
|
||||||
|
assert_eq!(15, *supply.get(&ShiftType::OpenFirst).unwrap());
|
||||||
|
assert_eq!(15, *supply.get(&ShiftType::OpenSecond).unwrap());
|
||||||
|
assert_eq!(15, *supply.get(&ShiftType::Closed).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_is_holiday_or_weekend(complex_config: UserConfig) {
|
||||||
|
assert!(!complex_config.is_holiday_or_weekend(Day(1)));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend(Day(4)));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend(Day(5)));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend(Day(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_is_holiday_or_weekend_slot(complex_config: UserConfig) {
|
||||||
|
let weekday = Slot::new(Day(1), ShiftPosition::First);
|
||||||
|
let sat = Slot::new(Day(4), ShiftPosition::First);
|
||||||
|
let sun = Slot::new(Day(5), ShiftPosition::First);
|
||||||
|
let manual_holiday = Slot::new(Day(10), ShiftPosition::First);
|
||||||
|
|
||||||
|
assert!(!complex_config.is_holiday_or_weekend_slot(weekday));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend_slot(sat));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend_slot(sun));
|
||||||
|
assert!(complex_config.is_holiday_or_weekend_slot(manual_holiday));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_toxic_pair_matches() {
|
||||||
|
let pair_1 = ToxicPair::new(1, 2);
|
||||||
|
let pair_1_r = ToxicPair::new(2, 1);
|
||||||
|
let pair_2 = ToxicPair::new(3, 1);
|
||||||
|
|
||||||
|
assert!(pair_1.matches(&pair_1_r));
|
||||||
|
assert!(pair_1_r.matches(&pair_1));
|
||||||
|
assert!(!pair_1.matches(&pair_2));
|
||||||
|
assert!(!pair_2.matches(&pair_1));
|
||||||
|
assert!(!pair_1_r.matches(&pair_2));
|
||||||
|
assert!(!pair_2.matches(&pair_1_r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
use log::Level;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ pub enum SearchError {
|
|||||||
NoSolutionFound,
|
NoSolutionFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SearchError {
|
||||||
|
pub fn log_level(&self) -> Level {
|
||||||
|
match self {
|
||||||
|
SearchError::SolutionFound | SearchError::ScheduleFull => Level::Info,
|
||||||
|
SearchError::NoSolutionFound => Level::Warn,
|
||||||
|
SearchError::Timeout | SearchError::Config(_) => Level::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<anyhow::Error> for SearchError {
|
impl From<anyhow::Error> for SearchError {
|
||||||
fn from(err: anyhow::Error) -> Self {
|
fn from(err: anyhow::Error) -> Self {
|
||||||
SearchError::Config(err.to_string())
|
SearchError::Config(err.to_string())
|
||||||
@@ -26,6 +37,8 @@ impl From<anyhow::Error> for SearchError {
|
|||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ExportError {
|
pub enum ExportError {
|
||||||
|
#[error("no schedule has been generated yet")]
|
||||||
|
NotGenerated,
|
||||||
#[error("path not found: {0}")]
|
#[error("path not found: {0}")]
|
||||||
InvalidPath(#[from] io::Error),
|
InvalidPath(#[from] io::Error),
|
||||||
#[error("docx packaging error: {0}")]
|
#[error("docx packaging error: {0}")]
|
||||||
@@ -46,6 +59,7 @@ impl Serialize for ExportError {
|
|||||||
s.serialize_field(
|
s.serialize_field(
|
||||||
"kind",
|
"kind",
|
||||||
match self {
|
match self {
|
||||||
|
ExportError::NotGenerated => "NotGenerated",
|
||||||
ExportError::InvalidPath(_) => "InvalidPath",
|
ExportError::InvalidPath(_) => "InvalidPath",
|
||||||
ExportError::Packaging(_) => "Packaging",
|
ExportError::Packaging(_) => "Packaging",
|
||||||
ExportError::OpenFailed(_) => "OpenFailed",
|
ExportError::OpenFailed(_) => "OpenFailed",
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ use docx_rs::{Docx, Paragraph, Run, RunFonts, Table, TableCell, TableRow};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::UserConfig,
|
||||||
errors::ExportError,
|
errors::ExportError,
|
||||||
schedule::MonthlySchedule,
|
schedule::{MonthlySchedule, ResidentMetrics},
|
||||||
slot::{month_to_greek, weekday_to_greek, Day, ShiftPosition, Slot},
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::WorkloadTracker,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -21,7 +20,7 @@ pub trait Export {
|
|||||||
&self,
|
&self,
|
||||||
file_type: FileType,
|
file_type: FileType,
|
||||||
config: &UserConfig,
|
config: &UserConfig,
|
||||||
tracker: &WorkloadTracker,
|
metrics: &[ResidentMetrics],
|
||||||
) -> Result<(), ExportError>;
|
) -> Result<(), ExportError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +29,10 @@ impl Export for MonthlySchedule {
|
|||||||
&self,
|
&self,
|
||||||
file_type: FileType,
|
file_type: FileType,
|
||||||
config: &UserConfig,
|
config: &UserConfig,
|
||||||
tracker: &WorkloadTracker,
|
metrics: &[ResidentMetrics],
|
||||||
) -> Result<(), ExportError> {
|
) -> Result<(), ExportError> {
|
||||||
match file_type {
|
match file_type {
|
||||||
FileType::Txt => self.export_as_txt(config, tracker)?,
|
FileType::Txt => self.export_as_txt(metrics)?,
|
||||||
FileType::Docx => self.export_as_docx(config)?,
|
FileType::Docx => self.export_as_docx(config)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,16 +41,11 @@ impl Export for MonthlySchedule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MonthlySchedule {
|
impl MonthlySchedule {
|
||||||
pub fn export_as_txt(
|
pub fn export_as_txt(&self, metrics: &[ResidentMetrics]) -> Result<(), ExportError> {
|
||||||
&self,
|
|
||||||
config: &UserConfig,
|
|
||||||
tracker: &WorkloadTracker,
|
|
||||||
) -> Result<(), ExportError> {
|
|
||||||
let file = File::create("rota.txt")?;
|
let file = File::create("rota.txt")?;
|
||||||
let mut writer = std::io::BufWriter::new(file);
|
let mut writer = std::io::BufWriter::new(file);
|
||||||
|
|
||||||
writer.write_all(self.pretty_print(config).as_bytes())?;
|
writer.write_all(self.report(metrics).as_bytes())?;
|
||||||
writer.write_all(self.report(config, tracker).as_bytes())?;
|
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -97,21 +91,21 @@ impl MonthlySchedule {
|
|||||||
let day = Day(d);
|
let day = Day(d);
|
||||||
let is_weekend = day.is_weekend(config.month.number_from_month(), config.year);
|
let is_weekend = day.is_weekend(config.month.number_from_month(), config.year);
|
||||||
let slot_first = Slot::new(Day(d), ShiftPosition::First);
|
let slot_first = Slot::new(Day(d), ShiftPosition::First);
|
||||||
let slot_first_res_id = self.get_resident_id(&slot_first);
|
let slot_first_r_id = self.get_resident_id(&slot_first);
|
||||||
let res_name_1 = config
|
let res_name_1 = config
|
||||||
.residents
|
.residents
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| Some(&r.id) == slot_first_res_id)
|
.find(|r| Some(&r.id) == slot_first_r_id)
|
||||||
.map(|r| r.name.as_str())
|
.map(|r| r.name.as_str())
|
||||||
.unwrap_or("-");
|
.unwrap_or("-");
|
||||||
|
|
||||||
let res_name_2 = if day.is_open_shift() {
|
let res_name_2 = if day.is_open_shift() {
|
||||||
let slot_second = Slot::new(Day(d), ShiftPosition::Second);
|
let slot_second = Slot::new(Day(d), ShiftPosition::Second);
|
||||||
let slot_second_res_id = self.get_resident_id(&slot_second);
|
let slot_second_r_id = self.get_resident_id(&slot_second);
|
||||||
config
|
config
|
||||||
.residents
|
.residents
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| Some(&r.id) == slot_second_res_id)
|
.find(|r| Some(&r.id) == slot_second_r_id)
|
||||||
.map(|r| r.name.as_str())
|
.map(|r| r.name.as_str())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -133,9 +127,9 @@ impl MonthlySchedule {
|
|||||||
),
|
),
|
||||||
TableCell::new().add_paragraph(
|
TableCell::new().add_paragraph(
|
||||||
Paragraph::new()
|
Paragraph::new()
|
||||||
.add_run(make_run(weekday_to_greek(
|
.add_run(make_run(
|
||||||
Day(d).weekday(config.month.number_from_month(), config.year),
|
Day(d).weekday_to_greek(config.month.number_from_month(), config.year),
|
||||||
)))
|
))
|
||||||
.fonts(RunFonts::new().ascii("Arial")),
|
.fonts(RunFonts::new().ascii("Arial")),
|
||||||
),
|
),
|
||||||
TableCell::new().add_paragraph(
|
TableCell::new().add_paragraph(
|
||||||
@@ -173,74 +167,20 @@ impl MonthlySchedule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn month_to_greek(month: u32) -> &'static str {
|
||||||
mod tests {
|
match month {
|
||||||
use rstest::{fixture, rstest};
|
1 => "Ιανουάριος",
|
||||||
|
2 => "Φεβρουάριος",
|
||||||
use crate::{
|
3 => "Μάρτιος",
|
||||||
config::UserConfig,
|
4 => "Απρίλιος",
|
||||||
export::{Export, FileType},
|
5 => "Μάιος",
|
||||||
resident::Resident,
|
6 => "Ιούνιος",
|
||||||
schedule::MonthlySchedule,
|
7 => "Ιούλιος",
|
||||||
scheduler::Scheduler,
|
8 => "Αύγουστος",
|
||||||
workload::{WorkloadBounds, WorkloadTracker},
|
9 => "Σεπτέμβριος",
|
||||||
};
|
10 => "Οκτώβριος",
|
||||||
|
11 => "Νοέμβριος",
|
||||||
#[fixture]
|
12 => "Δεκέμβριος",
|
||||||
fn schedule() -> MonthlySchedule {
|
_ => unreachable!("Invalid month: {}", month),
|
||||||
MonthlySchedule::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn config() -> UserConfig {
|
|
||||||
UserConfig::default().with_residents(vec![
|
|
||||||
Resident::new(1, "Στέφανος"),
|
|
||||||
Resident::new(2, "Ιορδάνης"),
|
|
||||||
Resident::new(3, "Μαρία"),
|
|
||||||
Resident::new(4, "Βεατρίκη"),
|
|
||||||
Resident::new(5, "Τάκης"),
|
|
||||||
Resident::new(6, "Μάκης"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn bounds(config: UserConfig) -> WorkloadBounds {
|
|
||||||
WorkloadBounds::new_with_config(&config)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn scheduler(config: UserConfig, bounds: WorkloadBounds) -> Scheduler {
|
|
||||||
Scheduler::new(config, bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn tracker() -> WorkloadTracker {
|
|
||||||
WorkloadTracker::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
pub fn test_export_as_txt(
|
|
||||||
mut schedule: MonthlySchedule,
|
|
||||||
mut tracker: WorkloadTracker,
|
|
||||||
scheduler: Scheduler,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
schedule.export(FileType::Txt, &scheduler.config, &tracker)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
pub fn test_generate_docx(
|
|
||||||
mut schedule: MonthlySchedule,
|
|
||||||
mut tracker: WorkloadTracker,
|
|
||||||
scheduler: Scheduler,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
schedule.generate_docx(&scheduler.config);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
src-tauri/src/fixtures.rs
Normal file
179
src-tauri/src/fixtures.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use crate::{
|
||||||
|
config::{ToxicPair, UserConfig},
|
||||||
|
resident::Resident,
|
||||||
|
schedule::ShiftType,
|
||||||
|
slot::{Day, ShiftPosition, Slot},
|
||||||
|
};
|
||||||
|
|
||||||
|
use rstest::fixture;
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
pub fn minimal_config() -> UserConfig {
|
||||||
|
UserConfig::default().with_residents(vec![
|
||||||
|
Resident::new(1, "R1"),
|
||||||
|
Resident::new(2, "R2"),
|
||||||
|
Resident::new(3, "R3"),
|
||||||
|
Resident::new(4, "R4"),
|
||||||
|
Resident::new(5, "R5"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
pub fn maximal_config() -> UserConfig {
|
||||||
|
UserConfig::default()
|
||||||
|
.with_holidays(vec![2, 3, 10, 11, 12, 25])
|
||||||
|
.with_residents(vec![
|
||||||
|
Resident::new(1, "R1").with_max_shifts(3),
|
||||||
|
Resident::new(2, "R2").with_max_shifts(4),
|
||||||
|
Resident::new(3, "R3").with_reduced_load(),
|
||||||
|
Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
|
||||||
|
Resident::new(5, "R5")
|
||||||
|
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
|
||||||
|
Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]),
|
||||||
|
Resident::new(7, "R7")
|
||||||
|
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
|
||||||
|
Resident::new(8, "R8"),
|
||||||
|
Resident::new(9, "R9"),
|
||||||
|
Resident::new(10, "R10").with_reduced_load(),
|
||||||
|
])
|
||||||
|
.with_toxic_pairs(vec![
|
||||||
|
ToxicPair::new(1, 2),
|
||||||
|
ToxicPair::new(3, 4),
|
||||||
|
ToxicPair::new(3, 5),
|
||||||
|
ToxicPair::new(7, 8),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
pub fn manual_shifts_heavy_config() -> UserConfig {
|
||||||
|
UserConfig::default().with_residents(vec![
|
||||||
|
Resident::new(1, "R1").with_manual_shifts(vec![
|
||||||
|
Slot::new(Day(1), ShiftPosition::First),
|
||||||
|
Slot::new(Day(3), ShiftPosition::First),
|
||||||
|
Slot::new(Day(5), ShiftPosition::Second),
|
||||||
|
]),
|
||||||
|
Resident::new(2, "R2").with_manual_shifts(vec![
|
||||||
|
Slot::new(Day(2), ShiftPosition::First),
|
||||||
|
Slot::new(Day(4), ShiftPosition::First),
|
||||||
|
]),
|
||||||
|
Resident::new(3, "R3"),
|
||||||
|
Resident::new(4, "R4"),
|
||||||
|
Resident::new(5, "R5"),
|
||||||
|
Resident::new(6, "R6"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
pub fn complex_config() -> UserConfig {
|
||||||
|
UserConfig::default()
|
||||||
|
.with_holidays(vec![5, 10, 12, 19])
|
||||||
|
.with_residents(vec![
|
||||||
|
Resident::new(1, "R1")
|
||||||
|
.with_max_shifts(3)
|
||||||
|
.with_negative_shifts(vec![Day(1), Day(2), Day(3)]),
|
||||||
|
Resident::new(2, "R2")
|
||||||
|
.with_max_shifts(3)
|
||||||
|
.with_negative_shifts(vec![Day(4), Day(5), Day(6)]),
|
||||||
|
Resident::new(3, "R3")
|
||||||
|
.with_max_shifts(3)
|
||||||
|
.with_negative_shifts(vec![Day(7), Day(8), Day(9)]),
|
||||||
|
Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
|
||||||
|
Resident::new(5, "R5")
|
||||||
|
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
|
||||||
|
Resident::new(6, "R6"),
|
||||||
|
Resident::new(7, "R7"),
|
||||||
|
Resident::new(8, "R8"),
|
||||||
|
Resident::new(9, "R9"),
|
||||||
|
])
|
||||||
|
.with_toxic_pairs(vec![
|
||||||
|
ToxicPair::new(1, 2),
|
||||||
|
ToxicPair::new(2, 3),
|
||||||
|
ToxicPair::new(5, 6),
|
||||||
|
ToxicPair::new(6, 7),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
pub fn hard_config() -> UserConfig {
|
||||||
|
UserConfig::default()
|
||||||
|
.with_holidays(vec![25])
|
||||||
|
.with_residents(vec![
|
||||||
|
Resident::new(1, "R1")
|
||||||
|
.with_negative_shifts(vec![
|
||||||
|
Day(2),
|
||||||
|
Day(3),
|
||||||
|
Day(4),
|
||||||
|
Day(5),
|
||||||
|
Day(6),
|
||||||
|
Day(7),
|
||||||
|
Day(8),
|
||||||
|
Day(9),
|
||||||
|
Day(13),
|
||||||
|
Day(14),
|
||||||
|
Day(15),
|
||||||
|
])
|
||||||
|
.with_manual_shifts(vec![
|
||||||
|
Slot::new(Day(1), ShiftPosition::First),
|
||||||
|
Slot::new(Day(12), ShiftPosition::First),
|
||||||
|
])
|
||||||
|
.with_max_shifts(6),
|
||||||
|
Resident::new(2, "R2")
|
||||||
|
.with_negative_shifts(vec![
|
||||||
|
Day(2),
|
||||||
|
Day(3),
|
||||||
|
Day(4),
|
||||||
|
Day(5),
|
||||||
|
Day(6),
|
||||||
|
Day(7),
|
||||||
|
Day(8),
|
||||||
|
Day(9),
|
||||||
|
])
|
||||||
|
.with_manual_shifts(vec![Slot::new(Day(1), ShiftPosition::Second)]),
|
||||||
|
Resident::new(3, "R3").with_negative_shifts(vec![
|
||||||
|
Day(12),
|
||||||
|
Day(13),
|
||||||
|
Day(14),
|
||||||
|
Day(15),
|
||||||
|
Day(16),
|
||||||
|
]),
|
||||||
|
Resident::new(4, "R4").with_negative_shifts(vec![Day(14), Day(15)]),
|
||||||
|
Resident::new(5, "R5")
|
||||||
|
.with_manual_shifts(vec![
|
||||||
|
Slot::new(Day(2), ShiftPosition::First),
|
||||||
|
Slot::new(Day(4), ShiftPosition::First),
|
||||||
|
Slot::new(Day(7), ShiftPosition::First),
|
||||||
|
Slot::new(Day(9), ShiftPosition::First),
|
||||||
|
])
|
||||||
|
.with_negative_shifts(vec![
|
||||||
|
Day(12),
|
||||||
|
Day(13),
|
||||||
|
Day(14),
|
||||||
|
Day(15),
|
||||||
|
Day(16),
|
||||||
|
Day(17),
|
||||||
|
Day(18),
|
||||||
|
Day(19),
|
||||||
|
Day(20),
|
||||||
|
Day(21),
|
||||||
|
Day(22),
|
||||||
|
Day(23),
|
||||||
|
Day(24),
|
||||||
|
Day(25),
|
||||||
|
]),
|
||||||
|
Resident::new(6, "R6")
|
||||||
|
.with_allowed_types(vec![ShiftType::OpenSecond])
|
||||||
|
.with_max_shifts(5),
|
||||||
|
Resident::new(7, "R7")
|
||||||
|
.with_max_shifts(5)
|
||||||
|
.with_negative_shifts(vec![Day(30), Day(31)]),
|
||||||
|
Resident::new(8, "R8")
|
||||||
|
.with_allowed_types(vec![ShiftType::OpenSecond])
|
||||||
|
.with_max_shifts(5),
|
||||||
|
])
|
||||||
|
.with_toxic_pairs(vec![
|
||||||
|
ToxicPair::new(3, 6),
|
||||||
|
ToxicPair::new(3, 8),
|
||||||
|
ToxicPair::new(3, 7),
|
||||||
|
ToxicPair::new(3, 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
config::{UserConfig, UserConfigDTO},
|
config::{UserConfig, UserConfigDTO},
|
||||||
errors::{ExportError, SearchError},
|
errors::{ExportError, SearchError},
|
||||||
export::{Export, FileType},
|
export::{Export, FileType},
|
||||||
schedule::MonthlySchedule,
|
schedule::{MonthlySchedule, ResidentMetrics},
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
workload::WorkloadTracker,
|
workload::WorkloadTracker,
|
||||||
};
|
};
|
||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod fixtures;
|
||||||
pub mod resident;
|
pub mod resident;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
@@ -24,7 +25,7 @@ pub mod workload;
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
schedule: Mutex<MonthlySchedule>,
|
schedule: Mutex<MonthlySchedule>,
|
||||||
tracker: Mutex<WorkloadTracker>,
|
tracker: Mutex<WorkloadTracker>,
|
||||||
config: Mutex<UserConfig>,
|
config: Mutex<Option<UserConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// argument to this must be the rota state including all
|
/// argument to this must be the rota state including all
|
||||||
@@ -57,19 +58,33 @@ fn generate(
|
|||||||
|
|
||||||
*internal_schedule = schedule.clone();
|
*internal_schedule = schedule.clone();
|
||||||
*internal_tracker = tracker.clone();
|
*internal_tracker = tracker.clone();
|
||||||
*internal_config = config.clone();
|
*internal_config = Some(config.clone());
|
||||||
|
|
||||||
Ok(schedule)
|
Ok(schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_metrics(state: tauri::State<'_, AppState>) -> Vec<ResidentMetrics> {
|
||||||
|
let schedule = state.schedule.lock().unwrap();
|
||||||
|
let tracker = state.tracker.lock().unwrap();
|
||||||
|
let config = state.config.lock().unwrap();
|
||||||
|
match config.as_ref() {
|
||||||
|
Some(c) => schedule.metrics(c, &tracker),
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
||||||
let schedule = state.schedule.lock().unwrap();
|
let schedule = state.schedule.lock().unwrap();
|
||||||
let tracker = state.tracker.lock().unwrap();
|
let tracker = state.tracker.lock().unwrap();
|
||||||
let config = state.config.lock().unwrap();
|
let config = state.config.lock().unwrap();
|
||||||
|
let config = config.as_ref().ok_or(ExportError::NotGenerated)?;
|
||||||
|
|
||||||
schedule.export(FileType::Docx, &config, &tracker)?;
|
let metrics = schedule.metrics(config, &tracker);
|
||||||
schedule.export(FileType::Txt, &config, &tracker)?;
|
|
||||||
|
schedule.export(FileType::Docx, config, &metrics)?;
|
||||||
|
schedule.export(FileType::Txt, config, &metrics)?;
|
||||||
|
|
||||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||||
info!("Files exported at {}", log_dir.display());
|
info!("Files exported at {}", log_dir.display());
|
||||||
@@ -85,7 +100,7 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
schedule: Mutex::new(MonthlySchedule::new()),
|
schedule: Mutex::new(MonthlySchedule::new()),
|
||||||
tracker: Mutex::new(WorkloadTracker::default()),
|
tracker: Mutex::new(WorkloadTracker::default()),
|
||||||
config: Mutex::new(UserConfig::default()),
|
config: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_log::Builder::new()
|
tauri_plugin_log::Builder::new()
|
||||||
@@ -99,15 +114,20 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![generate, export])
|
.invoke_handler(tauri::generate_handler![generate, export, get_metrics])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error while running tauri application");
|
.expect("Error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use rstest::rstest;
|
use ctor::ctor;
|
||||||
|
|
||||||
#[rstest]
|
#[ctor]
|
||||||
pub fn test_endpoints() {}
|
fn global_setup() {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.is_test(true)
|
||||||
|
.try_init();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ impl Resident {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn available_slots_on(&self, day: Day) -> u16 {
|
||||||
|
if self.negative_shifts.contains(&day) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
day.shift_types()
|
||||||
|
.iter()
|
||||||
|
.filter(|t| self.allowed_types.contains(t))
|
||||||
|
.count() as u16
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_negative_shifts(mut self, negative_shifts: Vec<Day>) -> Self {
|
pub fn with_negative_shifts(mut self, negative_shifts: Vec<Day>) -> Self {
|
||||||
self.negative_shifts = negative_shifts;
|
self.negative_shifts = negative_shifts;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ use serde::{ser::SerializeMap, Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ToxicPair, UserConfig},
|
config::UserConfig,
|
||||||
resident::ResidentId,
|
resident::ResidentId,
|
||||||
slot::{weekday_to_greek, Day, ShiftPosition, Slot},
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::{WorkloadBounds, WorkloadTracker},
|
workload::WorkloadTracker,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Serializer;
|
use serde::Serializer;
|
||||||
@@ -15,6 +15,16 @@ use serde::Serializer;
|
|||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Deserialize, Debug, Clone, Default)]
|
||||||
pub struct MonthlySchedule(pub HashMap<Slot, ResidentId>);
|
pub struct MonthlySchedule(pub HashMap<Slot, ResidentId>);
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ResidentMetrics {
|
||||||
|
pub name: String,
|
||||||
|
pub total: u8,
|
||||||
|
pub open_first: u8,
|
||||||
|
pub open_second: u8,
|
||||||
|
pub closed: u8,
|
||||||
|
pub holiday: u8,
|
||||||
|
}
|
||||||
|
|
||||||
impl MonthlySchedule {
|
impl MonthlySchedule {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
@@ -32,8 +42,8 @@ impl MonthlySchedule {
|
|||||||
self.0.get(slot)
|
self.0.get(slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert(&mut self, slot: Slot, resident_id: ResidentId) {
|
pub fn insert(&mut self, slot: Slot, r_id: ResidentId) {
|
||||||
self.0.insert(slot, resident_id);
|
self.0.insert(slot, r_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, slot: Slot) {
|
pub fn remove(&mut self, slot: Slot) {
|
||||||
@@ -44,32 +54,6 @@ impl MonthlySchedule {
|
|||||||
self.0.contains_key(slot)
|
self.0.contains_key(slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// if any restriction is violated => we return true (leading to pruning in the backtracking algorithm)
|
|
||||||
/// 1) no same person in consecutive days
|
|
||||||
/// 2) avoid input toxic pairs
|
|
||||||
/// 3) apply fairness on total shifts split residents also take into account reduced (-1) workload
|
|
||||||
///
|
|
||||||
/// @slot points to an occupied slot
|
|
||||||
/// @config info manually set on the GUI by the user
|
|
||||||
pub fn restrictions_violated(
|
|
||||||
&self,
|
|
||||||
slot: &Slot,
|
|
||||||
config: &UserConfig,
|
|
||||||
bounds: &WorkloadBounds,
|
|
||||||
tracker: &WorkloadTracker,
|
|
||||||
) -> bool {
|
|
||||||
let resident_id = match self.get_resident_id(slot) {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.has_resident_in_consecutive_days(slot)
|
|
||||||
|| self.has_toxic_pair(slot, config)
|
|
||||||
|| tracker.is_total_workload_exceeded(bounds, resident_id)
|
|
||||||
|| tracker.is_holiday_workload_exceeded(bounds, resident_id)
|
|
||||||
|| tracker.is_max_shift_type_exceeded(bounds, resident_id, slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
|
pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
|
||||||
if slot.day == Day(1) {
|
if slot.day == Day(1) {
|
||||||
return false;
|
return false;
|
||||||
@@ -89,45 +73,24 @@ impl MonthlySchedule {
|
|||||||
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot))
|
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_toxic_pair(&self, slot: &Slot, config: &UserConfig) -> bool {
|
|
||||||
// can only have caused a toxic pair violation if we just added a 2nd resident in an open shift
|
|
||||||
if !slot.is_open_second() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_id = self.get_resident_id(&slot.previous());
|
|
||||||
let second_id = self.get_resident_id(slot);
|
|
||||||
|
|
||||||
if let (Some(r1), Some(r2)) = (first_id, second_id) {
|
|
||||||
return config
|
|
||||||
.toxic_pairs
|
|
||||||
.iter()
|
|
||||||
.any(|pair| pair.matches(&ToxicPair::from((*r1, *r2))));
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
||||||
let mut sorted: Vec<_> = self.0.iter().collect();
|
let mut sorted: Vec<_> = self.0.iter().collect();
|
||||||
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
||||||
|
|
||||||
let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n");
|
let mut output = String::from("Μηνιαίο Πρόγραμμα Εφημεριών\n");
|
||||||
for (slot, res_id) in sorted {
|
for (slot, r_id) in sorted {
|
||||||
let res_name = config
|
let res_name = config
|
||||||
.residents
|
.residents
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| &r.id == res_id)
|
.find(|r| &r.id == r_id)
|
||||||
.map(|r| r.name.as_str())
|
.map(|r| r.name.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"Ημέρα {:2} - {:9} - {:11}: {},\n",
|
"Ημέρα {:2} - {:9} - {:11}: {},\n",
|
||||||
slot.day.0,
|
slot.day.0,
|
||||||
weekday_to_greek(
|
|
||||||
slot.day
|
slot.day
|
||||||
.weekday(config.month.number_from_month(), config.year)
|
.weekday_to_greek(config.month.number_from_month(), config.year),
|
||||||
),
|
|
||||||
slot.shift_type_str(),
|
slot.shift_type_str(),
|
||||||
res_name
|
res_name
|
||||||
));
|
));
|
||||||
@@ -135,9 +98,25 @@ impl MonthlySchedule {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn report(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String {
|
pub fn metrics(&self, config: &UserConfig, tracker: &WorkloadTracker) -> Vec<ResidentMetrics> {
|
||||||
|
let mut residents: Vec<_> = config.residents.iter().collect();
|
||||||
|
residents.sort_by_key(|r| &r.name);
|
||||||
|
residents
|
||||||
|
.iter()
|
||||||
|
.map(|r| ResidentMetrics {
|
||||||
|
name: r.name.clone(),
|
||||||
|
total: tracker.current_workload(&r.id),
|
||||||
|
open_first: tracker.current_shift_type_workload(&r.id, ShiftType::OpenFirst),
|
||||||
|
open_second: tracker.current_shift_type_workload(&r.id, ShiftType::OpenSecond),
|
||||||
|
closed: tracker.current_shift_type_workload(&r.id, ShiftType::Closed),
|
||||||
|
holiday: tracker.current_holiday_workload(&r.id),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn report(&self, metrics: &[ResidentMetrics]) -> String {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
output.push_str("\n--- Αναφορά ---\n");
|
output.push('\n');
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
||||||
"Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες"
|
"Ειδικευόμενος", "Σύνολο", "Ανοιχτή(1)", "Ανοιχτή(2)", "Κλειστή", "ΣΚ/Αργίες"
|
||||||
@@ -145,23 +124,26 @@ impl MonthlySchedule {
|
|||||||
output.push_str("-".repeat(85).as_str());
|
output.push_str("-".repeat(85).as_str());
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
|
|
||||||
let mut residents: Vec<_> = config.residents.iter().collect();
|
for m in metrics {
|
||||||
residents.sort_by_key(|r| &r.name);
|
|
||||||
|
|
||||||
for res in residents {
|
|
||||||
let total = tracker.current_workload(&res.id);
|
|
||||||
let o1 = tracker.get_type_count(&res.id, ShiftType::OpenFirst);
|
|
||||||
let o2 = tracker.get_type_count(&res.id, ShiftType::OpenSecond);
|
|
||||||
let cl = tracker.get_type_count(&res.id, ShiftType::Closed);
|
|
||||||
let holiday = tracker.current_holiday_workload(&res.id);
|
|
||||||
|
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
||||||
res.name, total, o1, o2, cl, holiday
|
m.name, m.total, m.open_first, m.open_second, m.closed, m.holiday
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push_str("-".repeat(85).as_str());
|
output.push_str("-".repeat(85).as_str());
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
|
|
||||||
|
let total: u8 = metrics.iter().map(|m| m.total).sum();
|
||||||
|
let o1: u8 = metrics.iter().map(|m| m.open_first).sum();
|
||||||
|
let o2: u8 = metrics.iter().map(|m| m.open_second).sum();
|
||||||
|
let cl: u8 = metrics.iter().map(|m| m.closed).sum();
|
||||||
|
let holiday: u8 = metrics.iter().map(|m| m.holiday).sum();
|
||||||
|
output.push_str(&format!(
|
||||||
|
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
||||||
|
"Σύνολο", total, o1, o2, cl, holiday
|
||||||
|
));
|
||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,45 +175,18 @@ pub enum ShiftType {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use rstest::{fixture, rstest};
|
use rstest::rstest;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ToxicPair, UserConfig},
|
|
||||||
resident::{Resident, ResidentId},
|
resident::{Resident, ResidentId},
|
||||||
schedule::{Day, MonthlySchedule, Slot},
|
schedule::{Day, MonthlySchedule, Slot},
|
||||||
slot::ShiftPosition,
|
slot::ShiftPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn schedule() -> MonthlySchedule {
|
|
||||||
MonthlySchedule::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn resident() -> Resident {
|
|
||||||
Resident::new(1, "Stefanos")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn toxic_config() -> UserConfig {
|
|
||||||
UserConfig::default()
|
|
||||||
.with_residents(vec![
|
|
||||||
Resident::new(1, "Stefanos"),
|
|
||||||
Resident::new(2, "Iordanis"),
|
|
||||||
])
|
|
||||||
.with_toxic_pairs(vec![ToxicPair::new(1, 2)])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn config() -> UserConfig {
|
|
||||||
UserConfig::default().with_residents(vec![
|
|
||||||
Resident::new(1, "Stefanos"),
|
|
||||||
Resident::new(2, "Iordanis"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_insert_resident(mut schedule: MonthlySchedule, resident: Resident) {
|
fn test_insert_resident() {
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let resident = Resident::new(1, "R1");
|
||||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||||
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
||||||
|
|
||||||
@@ -242,7 +197,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_remove_resident(mut schedule: MonthlySchedule, resident: Resident) {
|
fn test_remove_resident() {
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let resident = Resident::new(1, "R1");
|
||||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||||
|
|
||||||
schedule.insert(slot_1, resident.id);
|
schedule.insert(slot_1, resident.id);
|
||||||
@@ -252,7 +209,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_same_resident_in_consecutive_days(mut schedule: MonthlySchedule, resident: Resident) {
|
fn test_same_resident_in_consecutive_days() {
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let resident = Resident::new(1, "R1");
|
||||||
|
|
||||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||||
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
||||||
let slot_3 = Slot::new(Day(2), ShiftPosition::First);
|
let slot_3 = Slot::new(Day(2), ShiftPosition::First);
|
||||||
@@ -265,18 +225,4 @@ mod tests {
|
|||||||
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
|
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
|
||||||
assert!(schedule.has_resident_in_consecutive_days(&slot_3));
|
assert!(schedule.has_resident_in_consecutive_days(&slot_3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
fn test_has_toxic_pair(mut schedule: MonthlySchedule, toxic_config: UserConfig) {
|
|
||||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
|
||||||
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
|
|
||||||
|
|
||||||
let stefanos = &toxic_config.residents[0];
|
|
||||||
let iordanis = &toxic_config.residents[1];
|
|
||||||
|
|
||||||
schedule.insert(slot_1, stefanos.id);
|
|
||||||
schedule.insert(slot_2, iordanis.id);
|
|
||||||
|
|
||||||
assert!(schedule.has_toxic_pair(&slot_2, &toxic_config))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::{ToxicPair, UserConfig},
|
||||||
errors::SearchError,
|
errors::SearchError,
|
||||||
resident::ResidentId,
|
resident::ResidentId,
|
||||||
schedule::MonthlySchedule,
|
schedule::MonthlySchedule,
|
||||||
@@ -10,8 +10,7 @@ use crate::{
|
|||||||
workload::{WorkloadBounds, WorkloadTracker},
|
workload::{WorkloadBounds, WorkloadTracker},
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::info;
|
use rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng};
|
||||||
use rand::Rng;
|
|
||||||
use rayon::{
|
use rayon::{
|
||||||
current_thread_index,
|
current_thread_index,
|
||||||
iter::{IntoParallelRefIterator, ParallelIterator},
|
iter::{IntoParallelRefIterator, ParallelIterator},
|
||||||
@@ -48,8 +47,8 @@ impl Scheduler {
|
|||||||
tracker: &mut WorkloadTracker,
|
tracker: &mut WorkloadTracker,
|
||||||
) -> Result<bool, SearchError> {
|
) -> Result<bool, SearchError> {
|
||||||
schedule.prefill(&self.config);
|
schedule.prefill(&self.config);
|
||||||
for (slot, res_id) in schedule.0.iter() {
|
for (slot, r_id) in schedule.0.iter() {
|
||||||
tracker.insert(*res_id, &self.config, *slot);
|
tracker.insert(*r_id, &self.config, *slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: add validation
|
//TODO: add validation
|
||||||
@@ -60,10 +59,13 @@ impl Scheduler {
|
|||||||
.map(Slot::from)
|
.map(Slot::from)
|
||||||
.ok_or(SearchError::ScheduleFull)?;
|
.ok_or(SearchError::ScheduleFull)?;
|
||||||
|
|
||||||
let resident_ids = self.valid_residents(slot, schedule);
|
let mut valid_resident_ids = self.valid_residents(slot, schedule, tracker);
|
||||||
|
|
||||||
|
self.sort_residents(&mut valid_resident_ids, tracker, slot);
|
||||||
|
|
||||||
let solved_in_thread = AtomicBool::new(false);
|
let solved_in_thread = AtomicBool::new(false);
|
||||||
|
|
||||||
let sovled_state = resident_ids.par_iter().find_map_any(|&id| {
|
let solved_state = valid_resident_ids.par_iter().find_map_any(|&id| {
|
||||||
let mut local_schedule = schedule.clone();
|
let mut local_schedule = schedule.clone();
|
||||||
let mut local_tracker = tracker.clone();
|
let mut local_tracker = tracker.clone();
|
||||||
|
|
||||||
@@ -81,13 +83,14 @@ impl Scheduler {
|
|||||||
Ok(true) => Some((local_schedule, local_tracker)),
|
Ok(true) => Some((local_schedule, local_tracker)),
|
||||||
Ok(false) => None,
|
Ok(false) => None,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
info!("Thread Id: [{}] {}", current_thread_index().unwrap(), e);
|
let thread_id = current_thread_index().unwrap();
|
||||||
|
log::log!(e.log_level(), "Thread Id: [{}] {}", thread_id, e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some((solved_schedule, solved_tracker)) = sovled_state {
|
if let Some((solved_schedule, solved_tracker)) = solved_state {
|
||||||
*schedule = solved_schedule;
|
*schedule = solved_schedule;
|
||||||
*tracker = solved_tracker;
|
*tracker = solved_tracker;
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
@@ -114,32 +117,22 @@ impl Scheduler {
|
|||||||
return Err(SearchError::Timeout);
|
return Err(SearchError::Timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slot.is_first()
|
if schedule.has_resident_in_consecutive_days(&slot.previous()) {
|
||||||
&& schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker)
|
|
||||||
{
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if slot.greater_than(self.config.total_days) {
|
if self.found_solution(slot) {
|
||||||
if tracker.are_all_thresholds_met(&self.config, &self.bounds) {
|
|
||||||
solved_in_thread.store(true, Ordering::Relaxed);
|
solved_in_thread.store(true, Ordering::Relaxed);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.is_slot_manually_assigned(&slot) {
|
if schedule.is_slot_manually_assigned(&slot) {
|
||||||
return self.search(schedule, tracker, slot.next(), solved_in_thread);
|
return self.search(schedule, tracker, slot.next(), solved_in_thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort candidates by current workload, add rng for tie breakers
|
let mut valid_resident_ids = self.valid_residents(slot, schedule, tracker);
|
||||||
let mut valid_resident_ids = self.valid_residents(slot, schedule);
|
|
||||||
valid_resident_ids.sort_unstable_by_key(|res_id| {
|
self.sort_residents(&mut valid_resident_ids, tracker, slot);
|
||||||
let type_count = tracker.get_type_count(res_id, slot.shift_type());
|
|
||||||
let workload = tracker.current_workload(res_id);
|
|
||||||
let tie_breaker: f64 = rand::rng().random();
|
|
||||||
(type_count, workload, (tie_breaker * 1000.0) as usize)
|
|
||||||
});
|
|
||||||
|
|
||||||
for id in valid_resident_ids {
|
for id in valid_resident_ids {
|
||||||
schedule.insert(slot, id);
|
schedule.insert(slot, id);
|
||||||
@@ -156,10 +149,19 @@ impl Scheduler {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn found_solution(&self, slot: Slot) -> bool {
|
||||||
|
slot.greater_than(self.config.total_days)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return all valid residents for the current slot
|
/// Return all valid residents for the current slot
|
||||||
pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec<ResidentId> {
|
fn valid_residents(
|
||||||
let required_type = slot.shift_type();
|
&self,
|
||||||
let other_resident = slot
|
slot: Slot,
|
||||||
|
schedule: &MonthlySchedule,
|
||||||
|
tracker: &WorkloadTracker,
|
||||||
|
) -> Vec<ResidentId> {
|
||||||
|
let is_holiday_slot = self.config.is_holiday_or_weekend_slot(slot);
|
||||||
|
let other_resident_id = slot
|
||||||
.other_position()
|
.other_position()
|
||||||
.and_then(|partner_slot| schedule.get_resident_id(&partner_slot));
|
.and_then(|partner_slot| schedule.get_resident_id(&partner_slot));
|
||||||
|
|
||||||
@@ -167,85 +169,46 @@ impl Scheduler {
|
|||||||
.residents
|
.residents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
Some(&r.id) != other_resident
|
if let Some(other_id) = other_resident_id {
|
||||||
&& !r.negative_shifts.contains(&slot.day)
|
if &r.id == other_id {
|
||||||
&& r.allowed_types.contains(&required_type)
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.config
|
||||||
|
.toxic_pairs
|
||||||
|
.iter()
|
||||||
|
.any(|tp| tp.matches(&ToxicPair::from((r.id, *other_id))))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
!r.negative_shifts.contains(&slot.day)
|
||||||
|
&& r.allowed_types.contains(&slot.shift_type())
|
||||||
|
&& !tracker.reached_workload_limit(&self.bounds, &r.id)
|
||||||
|
&& (!is_holiday_slot || !tracker.reached_holiday_limit(&self.bounds, &r.id))
|
||||||
|
&& !tracker.reached_shift_type_limit(&self.bounds, &r.id, slot.shift_type())
|
||||||
})
|
})
|
||||||
.map(|r| r.id)
|
.map(|r| r.id)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
fn sort_residents(
|
||||||
mod tests {
|
&self,
|
||||||
use rstest::{fixture, rstest};
|
resident_ids: &mut [ResidentId],
|
||||||
|
tracker: &WorkloadTracker,
|
||||||
use crate::{
|
slot: Slot,
|
||||||
config::UserConfig,
|
|
||||||
resident::Resident,
|
|
||||||
schedule::MonthlySchedule,
|
|
||||||
scheduler::Scheduler,
|
|
||||||
slot::{Day, ShiftPosition, Slot},
|
|
||||||
workload::{WorkloadBounds, WorkloadTracker},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn schedule() -> MonthlySchedule {
|
|
||||||
MonthlySchedule::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn config() -> UserConfig {
|
|
||||||
UserConfig::default().with_residents(vec![
|
|
||||||
Resident::new(1, "Stefanos"),
|
|
||||||
Resident::new(2, "Iordanis"),
|
|
||||||
Resident::new(3, "Maria"),
|
|
||||||
Resident::new(4, "Veatriki"),
|
|
||||||
Resident::new(5, "Takis"),
|
|
||||||
Resident::new(6, "Akis"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn bounds(config: UserConfig) -> WorkloadBounds {
|
|
||||||
WorkloadBounds::new_with_config(&config)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn scheduler(config: UserConfig, bounds: WorkloadBounds) -> Scheduler {
|
|
||||||
Scheduler::new(config, bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn tracker() -> WorkloadTracker {
|
|
||||||
WorkloadTracker::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
fn test_search(
|
|
||||||
mut schedule: MonthlySchedule,
|
|
||||||
mut tracker: WorkloadTracker,
|
|
||||||
scheduler: Scheduler,
|
|
||||||
) {
|
) {
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker).is_ok());
|
let flex_map = self.config.flexibility_map(slot.day);
|
||||||
|
let mut rng = SmallRng::from_rng(&mut rand::rng());
|
||||||
for d in 1..=scheduler.config.total_days {
|
resident_ids.shuffle(&mut rng);
|
||||||
let day = Day(d);
|
resident_ids.sort_by_key(|r_id| {
|
||||||
if day.is_open_shift() {
|
let type_workload = tracker.current_shift_type_workload(r_id, slot.shift_type());
|
||||||
let slot_first = Slot::new(day, ShiftPosition::First);
|
let holiday_workload = tracker.current_holiday_workload(r_id);
|
||||||
assert!(schedule.get_resident_id(&slot_first).is_some());
|
let workload = tracker.current_workload(r_id);
|
||||||
let slot_second = Slot::new(day, ShiftPosition::Second);
|
let flex = flex_map.get(r_id).unwrap();
|
||||||
assert!(schedule.get_resident_id(&slot_second).is_some());
|
(flex, type_workload, workload, holiday_workload)
|
||||||
} else {
|
});
|
||||||
let slot_first = Slot::new(day, ShiftPosition::First);
|
|
||||||
assert!(schedule.get_resident_id(&slot_first).is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for r in &scheduler.config.residents {
|
|
||||||
let workload = tracker.current_workload(&r.id);
|
|
||||||
let limit = *scheduler.bounds.max_workloads.get(&r.id).unwrap();
|
|
||||||
assert!(workload <= limit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ impl Slot {
|
|||||||
self.day == Day(1) && self.position == ShiftPosition::First
|
self.day == Day(1) && self.position == ShiftPosition::First
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_first_day(&self) -> bool {
|
||||||
|
self.day == Day(1)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_open_first(&self) -> bool {
|
pub fn is_open_first(&self) -> bool {
|
||||||
self.is_open_shift() && self.position == ShiftPosition::First
|
self.is_open_shift() && self.position == ShiftPosition::First
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,7 @@ impl Slot {
|
|||||||
day: self.day,
|
day: self.day,
|
||||||
position: ShiftPosition::Second,
|
position: ShiftPosition::Second,
|
||||||
},
|
},
|
||||||
_ => Self {
|
ShiftPosition::First | ShiftPosition::Second => Self {
|
||||||
day: self.day.next(),
|
day: self.day.next(),
|
||||||
position: ShiftPosition::First,
|
position: ShiftPosition::First,
|
||||||
},
|
},
|
||||||
@@ -132,6 +136,14 @@ impl Day {
|
|||||||
!self.0.is_multiple_of(2)
|
!self.0.is_multiple_of(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn shift_types(&self) -> &[ShiftType] {
|
||||||
|
if self.is_open_shift() {
|
||||||
|
&[ShiftType::OpenFirst, ShiftType::OpenSecond]
|
||||||
|
} else {
|
||||||
|
&[ShiftType::Closed]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn next(&self) -> Self {
|
pub fn next(&self) -> Self {
|
||||||
Self(self.0 + 1)
|
Self(self.0 + 1)
|
||||||
}
|
}
|
||||||
@@ -160,20 +172,9 @@ impl Day {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn weekday(&self, month: u32, year: i32) -> Weekday {
|
pub fn weekday_to_greek(&self, month: u32, year: i32) -> &'static str {
|
||||||
let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32).unwrap();
|
let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32).unwrap();
|
||||||
date.weekday()
|
match date.weekday() {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
|
|
||||||
pub enum ShiftPosition {
|
|
||||||
First,
|
|
||||||
Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn weekday_to_greek(weekday: Weekday) -> &'static str {
|
|
||||||
match weekday {
|
|
||||||
Weekday::Mon => "Δευτέρα",
|
Weekday::Mon => "Δευτέρα",
|
||||||
Weekday::Tue => "Τρίτη",
|
Weekday::Tue => "Τρίτη",
|
||||||
Weekday::Wed => "Τετάρτη",
|
Weekday::Wed => "Τετάρτη",
|
||||||
@@ -183,23 +184,12 @@ pub fn weekday_to_greek(weekday: Weekday) -> &'static str {
|
|||||||
Weekday::Sun => "Κυριακή",
|
Weekday::Sun => "Κυριακή",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn month_to_greek(month: u32) -> &'static str {
|
|
||||||
match month {
|
|
||||||
1 => "Ιανουάριος",
|
|
||||||
2 => "Φεβρουάριος",
|
|
||||||
3 => "Μάρτιος",
|
|
||||||
4 => "Απρίλιος",
|
|
||||||
5 => "Μάιος",
|
|
||||||
6 => "Ιούνιος",
|
|
||||||
7 => "Ιούλιος",
|
|
||||||
8 => "Αύγουστος",
|
|
||||||
9 => "Σεπτέμβριος",
|
|
||||||
10 => "Οκτώβριος",
|
|
||||||
11 => "Νοέμβριος",
|
|
||||||
12 => "Δεκέμβριος",
|
|
||||||
_ => panic!("Unable to find translation for month {}", month),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
|
||||||
|
pub enum ShiftPosition {
|
||||||
|
First,
|
||||||
|
Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -225,6 +215,14 @@ mod tests {
|
|||||||
assert!(!slot_2.is_first());
|
assert!(!slot_2.is_first());
|
||||||
assert!(!slot_3.is_first());
|
assert!(!slot_3.is_first());
|
||||||
|
|
||||||
|
assert!(slot_1.is_first_day());
|
||||||
|
assert!(slot_2.is_first_day());
|
||||||
|
assert!(!slot_3.is_first_day());
|
||||||
|
|
||||||
|
assert!(slot_1.is_open_first());
|
||||||
|
assert!(!slot_2.is_open_first());
|
||||||
|
assert!(!slot_3.is_open_first());
|
||||||
|
|
||||||
assert!(!slot_1.is_open_second());
|
assert!(!slot_1.is_open_second());
|
||||||
assert!(slot_2.is_open_second());
|
assert!(slot_2.is_open_second());
|
||||||
assert!(!slot_3.is_open_second());
|
assert!(!slot_3.is_open_second());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub const TIME_LIMIT_IN_MS: u128 = 100000;
|
pub const TIME_LIMIT_IN_MS: u128 = 5000; // 5 sec
|
||||||
|
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
instant: Instant,
|
instant: Instant,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::UserConfig,
|
||||||
resident::ResidentId,
|
resident::{Resident, ResidentId},
|
||||||
schedule::ShiftType,
|
schedule::ShiftType,
|
||||||
slot::{Day, Slot},
|
slot::Slot,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -12,175 +12,95 @@ pub struct WorkloadBounds {
|
|||||||
pub max_workloads: HashMap<ResidentId, u8>,
|
pub max_workloads: HashMap<ResidentId, u8>,
|
||||||
pub max_holiday_shifts: HashMap<ResidentId, u8>,
|
pub max_holiday_shifts: HashMap<ResidentId, u8>,
|
||||||
pub max_by_shift_type: HashMap<(ResidentId, ShiftType), u8>,
|
pub max_by_shift_type: HashMap<(ResidentId, ShiftType), u8>,
|
||||||
pub min_by_shift_type: HashMap<(ResidentId, ShiftType), u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkloadBounds {
|
impl WorkloadBounds {
|
||||||
pub fn new_with_config(config: &UserConfig) -> Self {
|
pub fn new_with_config(config: &UserConfig) -> Self {
|
||||||
|
let residents = &config.residents;
|
||||||
|
let total_slots = config.total_slots;
|
||||||
|
let total_holiday_slots = config.total_holiday_slots;
|
||||||
|
|
||||||
let mut bounds = Self::default();
|
let mut bounds = Self::default();
|
||||||
bounds.calculate_max_workloads(config);
|
bounds.calculate_max_workloads(residents, total_slots);
|
||||||
bounds.calculate_max_holiday_shifts(config);
|
debug_assert!(bounds.max_workloads.values().sum::<u8>() >= total_slots);
|
||||||
bounds.calculate_max_by_shift_type(config);
|
bounds.calculate_max_holiday_shifts(residents, total_holiday_slots);
|
||||||
|
debug_assert!(bounds.max_holiday_shifts.values().sum::<u8>() >= total_holiday_slots);
|
||||||
|
bounds.calculate_max_by_shift_type(residents);
|
||||||
|
debug_assert!(bounds.max_by_shift_type.values().sum::<u8>() >= total_slots);
|
||||||
bounds
|
bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
/// get map with total amount of slots in a month for each type of shift
|
pub fn calculate_max_workloads(&mut self, residents: &[Resident], total_slots: u8) {
|
||||||
pub fn get_initial_supply(&self, config: &UserConfig) -> HashMap<ShiftType, u8> {
|
let non_manual_residents: Vec<_> = residents
|
||||||
let mut supply = HashMap::new();
|
|
||||||
let total_days = config.total_days;
|
|
||||||
|
|
||||||
for d in 1..=total_days {
|
|
||||||
if Day(d).is_open_shift() {
|
|
||||||
*supply.entry(ShiftType::OpenFirst).or_insert(0) += 1;
|
|
||||||
*supply.entry(ShiftType::OpenSecond).or_insert(0) += 1;
|
|
||||||
} else {
|
|
||||||
*supply.entry(ShiftType::Closed).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
supply
|
|
||||||
}
|
|
||||||
|
|
||||||
/// this is called after the user config params have been initialized, can be done with the builder (lite) pattern
|
|
||||||
/// initialize a hashmap for O(1) search calls for the residents' max workload
|
|
||||||
pub fn calculate_max_workloads(&mut self, config: &UserConfig) {
|
|
||||||
let auto_computed_residents: Vec<_> = config
|
|
||||||
.residents
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.max_shifts.is_none())
|
.filter(|r| r.max_shifts.is_none())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// if all residents have a manually set max shifts size, just use those values for the max workload
|
// all residents' max workload were manually inserted
|
||||||
if auto_computed_residents.is_empty() {
|
if non_manual_residents.is_empty() {
|
||||||
for r in &config.residents {
|
for r in residents {
|
||||||
self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0));
|
self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Untested scenario: Resident has manual max_shifts and also reduced workload flag
|
let total_manual_workload: u8 = residents.iter().map(|r| r.max_shifts.unwrap_or(0)).sum();
|
||||||
// Probably should forbid using both options from GUI
|
let total_reduced_workload: u8 = residents
|
||||||
|
|
||||||
let manual_max_shifts_sum: u8 = config
|
|
||||||
.residents
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.max_shifts.unwrap_or(0))
|
.map(|r| if r.reduced_load { 1 } else { 0 })
|
||||||
.sum();
|
.sum();
|
||||||
|
let remaining_slots = total_slots - total_manual_workload + total_reduced_workload;
|
||||||
|
let workload_share = remaining_slots.div_ceil(non_manual_residents.len() as u8);
|
||||||
|
|
||||||
let max_shifts_ceiling = ((config.total_slots - manual_max_shifts_sum) as f32
|
for r in residents {
|
||||||
/ auto_computed_residents.len() as f32)
|
let max_workload = match r.max_shifts {
|
||||||
.ceil() as u8;
|
Some(max_shifts) => max_shifts,
|
||||||
|
None if r.reduced_load => workload_share - 1,
|
||||||
for r in &config.residents {
|
None => workload_share,
|
||||||
let max_shifts = match r.max_shifts {
|
|
||||||
Some(shifts) => shifts,
|
|
||||||
None if r.reduced_load => max_shifts_ceiling - 1,
|
|
||||||
None => max_shifts_ceiling,
|
|
||||||
};
|
};
|
||||||
self.max_workloads.insert(r.id, max_shifts);
|
self.max_workloads.insert(r.id, max_workload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_max_holiday_shifts(&mut self, config: &UserConfig) {
|
pub fn calculate_max_holiday_shifts(
|
||||||
let total_slots = config.total_slots;
|
&mut self,
|
||||||
let total_holiday_slots = config.total_holiday_slots;
|
residents: &[Resident],
|
||||||
|
total_holiday_slots: u8,
|
||||||
for r in &config.residents {
|
) {
|
||||||
let workload_limit = *self.max_workloads.get(&r.id).unwrap_or(&0);
|
let total_residents = residents.len();
|
||||||
|
let holiday_share = total_holiday_slots.div_ceil(total_residents as u8);
|
||||||
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32;
|
for r in residents {
|
||||||
let holiday_limit = share.ceil() as u8;
|
self.max_holiday_shifts.insert(r.id, holiday_share);
|
||||||
|
|
||||||
self.max_holiday_shifts.insert(r.id, holiday_limit);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_max_by_shift_type(&mut self, config: &UserConfig) {
|
pub fn calculate_max_by_shift_type(&mut self, residents: &[Resident]) {
|
||||||
let mut supply_by_shift_type = self.get_initial_supply(config);
|
let mut upper_limits = HashMap::new();
|
||||||
let mut local_limits = HashMap::new();
|
let shift_types = [
|
||||||
let mut local_thresholds = HashMap::new();
|
|
||||||
|
|
||||||
let all_shift_types = [
|
|
||||||
ShiftType::OpenFirst,
|
ShiftType::OpenFirst,
|
||||||
ShiftType::OpenSecond,
|
ShiftType::OpenSecond,
|
||||||
ShiftType::Closed,
|
ShiftType::Closed,
|
||||||
];
|
];
|
||||||
|
|
||||||
// residents with 1 available shift types
|
for r in residents {
|
||||||
for res in config
|
let total_limit = *self.max_workloads.get(&r.id).unwrap_or(&0);
|
||||||
.residents
|
let n_allowed = r.allowed_types.len();
|
||||||
.iter()
|
|
||||||
.filter(|r| r.allowed_types.len() == 1)
|
|
||||||
{
|
|
||||||
let shift_type = res.allowed_types[0];
|
|
||||||
let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0);
|
|
||||||
|
|
||||||
local_limits.insert((res.id, shift_type), total_limit);
|
for shift_type in shift_types {
|
||||||
local_thresholds.insert((res.id, shift_type), total_limit.saturating_sub(2));
|
let limit = if r.allowed_types.contains(&shift_type) {
|
||||||
|
if n_allowed == 1 {
|
||||||
for other_type in all_shift_types {
|
total_limit
|
||||||
if other_type != shift_type {
|
} else {
|
||||||
local_limits.insert((res.id, other_type), 0);
|
(total_limit as f32 / n_allowed as f32).floor() as u8 + 1
|
||||||
local_thresholds.insert((res.id, other_type), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(s) = supply_by_shift_type.get_mut(&shift_type) {
|
|
||||||
*s = s.saturating_sub(total_limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// residents with 2 available shift types
|
|
||||||
for res in config
|
|
||||||
.residents
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.allowed_types.len() == 2)
|
|
||||||
{
|
|
||||||
let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0);
|
|
||||||
let per_type = ((total_limit as f32) / 2.0).ceil() as u8;
|
|
||||||
|
|
||||||
let deduct_amount = (total_limit as f32 / 2.0) as u8;
|
|
||||||
|
|
||||||
for shift_type in all_shift_types {
|
|
||||||
if res.allowed_types.contains(&shift_type) {
|
|
||||||
local_limits.insert((res.id, shift_type), per_type);
|
|
||||||
local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2));
|
|
||||||
if let Some(s) = supply_by_shift_type.get_mut(&shift_type) {
|
|
||||||
*s = s.saturating_sub(deduct_amount);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
local_limits.insert((res.id, shift_type), 0);
|
0
|
||||||
local_thresholds.insert((res.id, shift_type), 0);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// residents with 3 available shift types
|
upper_limits.insert((r.id, shift_type), limit);
|
||||||
for res in config
|
|
||||||
.residents
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.allowed_types.len() == 3)
|
|
||||||
{
|
|
||||||
let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0);
|
|
||||||
let per_type = ((total_limit as f32) / 3.0).ceil() as u8;
|
|
||||||
|
|
||||||
let deduct_amount = (total_limit as f32 / 3.0) as u8;
|
|
||||||
|
|
||||||
for shift_type in all_shift_types {
|
|
||||||
if res.allowed_types.contains(&shift_type) {
|
|
||||||
local_limits.insert((res.id, shift_type), per_type);
|
|
||||||
local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2));
|
|
||||||
if let Some(s) = supply_by_shift_type.get_mut(&shift_type) {
|
|
||||||
*s = s.saturating_sub(deduct_amount);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
local_limits.insert((res.id, shift_type), 0);
|
|
||||||
local_thresholds.insert((res.id, shift_type), 0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
self.max_by_shift_type = upper_limits;
|
||||||
|
|
||||||
self.max_by_shift_type = local_limits;
|
|
||||||
self.min_by_shift_type = local_thresholds;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,198 +108,380 @@ impl WorkloadBounds {
|
|||||||
pub struct WorkloadTracker {
|
pub struct WorkloadTracker {
|
||||||
total_counts: HashMap<ResidentId, u8>,
|
total_counts: HashMap<ResidentId, u8>,
|
||||||
type_counts: HashMap<(ResidentId, ShiftType), u8>,
|
type_counts: HashMap<(ResidentId, ShiftType), u8>,
|
||||||
holidays: HashMap<ResidentId, u8>,
|
holiday_counts: HashMap<ResidentId, u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkloadTracker {
|
impl WorkloadTracker {
|
||||||
pub fn insert(&mut self, res_id: ResidentId, config: &UserConfig, slot: Slot) {
|
pub fn insert(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||||
*self.total_counts.entry(res_id).or_insert(0) += 1;
|
*self.total_counts.entry(r_id).or_insert(0) += 1;
|
||||||
*self
|
*self
|
||||||
.type_counts
|
.type_counts
|
||||||
.entry((res_id, slot.shift_type()))
|
.entry((r_id, slot.shift_type()))
|
||||||
.or_insert(0) += 1;
|
.or_insert(0) += 1;
|
||||||
|
|
||||||
if config.is_holiday_or_weekend_slot(slot.day.0) {
|
if config.is_holiday_or_weekend_slot(slot) {
|
||||||
*self.holidays.entry(res_id).or_insert(0) += 1;
|
*self.holiday_counts.entry(r_id).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, resident_id: ResidentId, config: &UserConfig, slot: Slot) {
|
pub fn remove(&mut self, r_id: ResidentId, config: &UserConfig, slot: Slot) {
|
||||||
if let Some(count) = self.total_counts.get_mut(&resident_id) {
|
if let Some(count) = self.total_counts.get_mut(&r_id) {
|
||||||
*count = count.saturating_sub(1);
|
*count = count.saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(count) = self.type_counts.get_mut(&(resident_id, slot.shift_type())) {
|
if let Some(count) = self.type_counts.get_mut(&(r_id, slot.shift_type())) {
|
||||||
*count = count.saturating_sub(1);
|
*count = count.saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.is_holiday_or_weekend_slot(slot.day.0) {
|
if config.is_holiday_or_weekend_slot(slot) {
|
||||||
if let Some(count) = self.holidays.get_mut(&resident_id) {
|
if let Some(count) = self.holiday_counts.get_mut(&r_id) {
|
||||||
*count = count.saturating_sub(1);
|
*count = count.saturating_sub(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_workload(&self, res_id: &ResidentId) -> u8 {
|
pub fn current_workload(&self, r_id: &ResidentId) -> u8 {
|
||||||
*self.total_counts.get(res_id).unwrap_or(&0)
|
*self.total_counts.get(r_id).unwrap_or(&0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_holiday_workload(&self, resident_id: &ResidentId) -> u8 {
|
pub fn current_holiday_workload(&self, r_id: &ResidentId) -> u8 {
|
||||||
*self.holidays.get(resident_id).unwrap_or(&0)
|
*self.holiday_counts.get(r_id).unwrap_or(&0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn are_all_thresholds_met(&self, config: &UserConfig, bounds: &WorkloadBounds) -> bool {
|
pub fn current_shift_type_workload(&self, r_id: &ResidentId, shift_type: ShiftType) -> u8 {
|
||||||
const SHIFT_TYPES: [ShiftType; 3] = [
|
*self.type_counts.get(&(*r_id, shift_type)).unwrap_or(&0)
|
||||||
ShiftType::OpenFirst,
|
|
||||||
ShiftType::OpenSecond,
|
|
||||||
ShiftType::Closed,
|
|
||||||
];
|
|
||||||
|
|
||||||
for r in &config.residents {
|
|
||||||
for shift_type in SHIFT_TYPES {
|
|
||||||
let current_load = self.type_counts.get(&(r.id, shift_type)).unwrap_or(&0);
|
|
||||||
if let Some(&min) = bounds.min_by_shift_type.get(&(r.id, shift_type)) {
|
|
||||||
if *current_load < min {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_total_workload_exceeded(
|
pub fn reached_workload_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
|
||||||
&self,
|
let current_load = self.current_workload(r_id);
|
||||||
bounds: &WorkloadBounds,
|
if let Some(&max) = bounds.max_workloads.get(r_id) {
|
||||||
resident_id: &ResidentId,
|
return current_load >= max;
|
||||||
) -> bool {
|
|
||||||
let current_load = self.current_workload(resident_id);
|
|
||||||
|
|
||||||
if let Some(&max) = bounds.max_workloads.get(resident_id) {
|
|
||||||
if current_load > max {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_holiday_workload_exceeded(
|
pub fn reached_holiday_limit(&self, bounds: &WorkloadBounds, r_id: &ResidentId) -> bool {
|
||||||
&self,
|
let current_load = self.current_holiday_workload(r_id);
|
||||||
bounds: &WorkloadBounds,
|
if let Some(&max) = bounds.max_holiday_shifts.get(r_id) {
|
||||||
resident_id: &ResidentId,
|
return current_load >= max;
|
||||||
) -> bool {
|
|
||||||
let current_load = self.current_holiday_workload(resident_id);
|
|
||||||
|
|
||||||
if let Some(&max) = bounds.max_holiday_shifts.get(resident_id) {
|
|
||||||
if current_load > max {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_max_shift_type_exceeded(
|
pub fn reached_shift_type_limit(
|
||||||
&self,
|
&self,
|
||||||
bounds: &WorkloadBounds,
|
bounds: &WorkloadBounds,
|
||||||
resident_id: &ResidentId,
|
r_id: &ResidentId,
|
||||||
slot: &Slot,
|
shift_type: ShiftType,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let shift_type = slot.shift_type();
|
let current_load = self.current_shift_type_workload(r_id, shift_type);
|
||||||
let current_load = self
|
if let Some(&max) = bounds.max_by_shift_type.get(&(*r_id, shift_type)) {
|
||||||
.type_counts
|
return current_load >= max;
|
||||||
.get(&(*resident_id, shift_type))
|
|
||||||
.unwrap_or(&0);
|
|
||||||
|
|
||||||
if let Some(&max) = bounds.max_by_shift_type.get(&(*resident_id, shift_type)) {
|
|
||||||
return *current_load > max;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_type_count(&self, res_id: &ResidentId, shift_type: ShiftType) -> u8 {
|
|
||||||
*self.type_counts.get(&(*res_id, shift_type)).unwrap_or(&0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::UserConfig,
|
||||||
resident::{Resident, ResidentId},
|
fixtures::{complex_config, hard_config, minimal_config},
|
||||||
|
resident::ResidentId,
|
||||||
|
schedule::ShiftType,
|
||||||
slot::{Day, ShiftPosition, Slot},
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::{WorkloadBounds, WorkloadTracker},
|
workload::{WorkloadBounds, WorkloadTracker},
|
||||||
};
|
};
|
||||||
use rstest::{fixture, rstest};
|
use rstest::rstest;
|
||||||
|
|
||||||
#[fixture]
|
// Testing WorkloadBounds
|
||||||
fn config() -> UserConfig {
|
|
||||||
UserConfig::default().with_residents(vec![
|
|
||||||
Resident::new(1, "Stefanos").with_max_shifts(2),
|
|
||||||
Resident::new(2, "Iordanis").with_max_shifts(2),
|
|
||||||
Resident::new(3, "Maria").with_reduced_load(),
|
|
||||||
Resident::new(4, "Veatriki"),
|
|
||||||
Resident::new(5, "Takis"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
#[rstest]
|
||||||
fn tracker() -> WorkloadTracker {
|
fn test_max_workloads(mut minimal_config: UserConfig) {
|
||||||
WorkloadTracker::default()
|
minimal_config.update_month(4);
|
||||||
|
let bounds = WorkloadBounds::new_with_config(&minimal_config);
|
||||||
|
assert_eq!(9, bounds.max_workloads[&ResidentId(1)]);
|
||||||
|
assert_eq!(9, bounds.max_workloads[&ResidentId(2)]);
|
||||||
|
assert_eq!(9, bounds.max_workloads[&ResidentId(3)]);
|
||||||
|
assert_eq!(9, bounds.max_workloads[&ResidentId(4)]);
|
||||||
|
assert_eq!(9, bounds.max_workloads[&ResidentId(5)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_max_workloads(config: UserConfig) {
|
fn test_calculate_max_workloads_minimal(minimal_config: UserConfig) {
|
||||||
let bounds = WorkloadBounds::new_with_config(&config);
|
|
||||||
|
|
||||||
assert_eq!(bounds.max_workloads[&ResidentId(1)], 2);
|
|
||||||
assert_eq!(bounds.max_workloads[&ResidentId(2)], 2);
|
|
||||||
assert!(bounds.max_workloads[&ResidentId(3)] > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rstest]
|
|
||||||
fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
|
|
||||||
let res_id = ResidentId(1);
|
|
||||||
let mut bounds = WorkloadBounds::default();
|
let mut bounds = WorkloadBounds::default();
|
||||||
bounds.max_workloads.insert(res_id, 1);
|
bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots);
|
||||||
|
|
||||||
|
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||||
|
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||||
|
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||||
|
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||||
|
assert_eq!(9, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_workloads_complex(complex_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots);
|
||||||
|
|
||||||
|
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||||
|
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||||
|
assert_eq!(3, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(6)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(7)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(8)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(9)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_workloads_hard(hard_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots);
|
||||||
|
|
||||||
|
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(6)).unwrap());
|
||||||
|
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(7)).unwrap());
|
||||||
|
assert_eq!(5, *bounds.max_workloads.get(&ResidentId(8)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(1)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(2)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(3)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(4)).unwrap());
|
||||||
|
assert_eq!(6, *bounds.max_workloads.get(&ResidentId(5)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_holiday_shifts_complex(complex_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_holiday_shifts(
|
||||||
|
&complex_config.residents,
|
||||||
|
complex_config.total_holiday_slots,
|
||||||
|
);
|
||||||
|
|
||||||
|
for i in 1..=9 {
|
||||||
|
assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_holiday_shifts_minimal(minimal_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_holiday_shifts(
|
||||||
|
&minimal_config.residents,
|
||||||
|
minimal_config.total_holiday_slots,
|
||||||
|
);
|
||||||
|
|
||||||
|
for i in 1..=5 {
|
||||||
|
assert_eq!(3, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_holiday_shifts_hard(hard_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds
|
||||||
|
.calculate_max_holiday_shifts(&hard_config.residents, hard_config.total_holiday_slots);
|
||||||
|
|
||||||
|
for i in 1..=8 {
|
||||||
|
assert_eq!(2, *bounds.max_holiday_shifts.get(&ResidentId(i)).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_by_shift_type_minimal(minimal_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_workloads(&minimal_config.residents, minimal_config.total_slots);
|
||||||
|
bounds.calculate_max_by_shift_type(&minimal_config.residents);
|
||||||
|
let m = bounds.max_by_shift_type;
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_by_shift_type_complex(complex_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_workloads(&complex_config.residents, complex_config.total_slots);
|
||||||
|
bounds.calculate_max_by_shift_type(&complex_config.residents);
|
||||||
|
let m = bounds.max_by_shift_type;
|
||||||
|
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(6, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(4, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(9), ShiftType::Closed)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_calculate_max_by_shift_type_hard(hard_config: UserConfig) {
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.calculate_max_workloads(&hard_config.residents, hard_config.total_slots);
|
||||||
|
bounds.calculate_max_by_shift_type(&hard_config.residents);
|
||||||
|
let m = bounds.max_by_shift_type;
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(1), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(2), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(3), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(4), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(3, *m.get(&(ResidentId(5), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(5, *m.get(&(ResidentId(6), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(6), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(2, *m.get(&(ResidentId(7), ShiftType::Closed)).unwrap());
|
||||||
|
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::OpenFirst)).unwrap());
|
||||||
|
assert_eq!(5, *m.get(&(ResidentId(8), ShiftType::OpenSecond)).unwrap());
|
||||||
|
assert_eq!(0, *m.get(&(ResidentId(8), ShiftType::Closed)).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing WorkloadTracker
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_reached_workload_limit(minimal_config: UserConfig) {
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let r_id = ResidentId(1);
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds.max_workloads.insert(r_id, 1);
|
||||||
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||||
let slot_2 = Slot::new(Day(2), ShiftPosition::First);
|
let slot_2 = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
|
||||||
tracker.insert(res_id, &config, slot_1);
|
assert!(!tracker.reached_workload_limit(&bounds, &r_id));
|
||||||
assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,));
|
tracker.insert(r_id, &minimal_config, slot_1);
|
||||||
|
assert!(tracker.reached_workload_limit(&bounds, &r_id));
|
||||||
tracker.insert(res_id, &config, slot_2);
|
tracker.insert(r_id, &minimal_config, slot_2);
|
||||||
assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,));
|
assert!(tracker.reached_workload_limit(&bounds, &r_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
|
fn test_reached_holiday_limit(minimal_config: UserConfig) {
|
||||||
let res_id = ResidentId(1);
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let r_id = ResidentId(1);
|
||||||
let mut bounds = WorkloadBounds::default();
|
let mut bounds = WorkloadBounds::default();
|
||||||
bounds.max_holiday_shifts.insert(res_id, 1);
|
bounds.max_holiday_shifts.insert(r_id, 1);
|
||||||
|
let sat = Slot::new(Day(11), ShiftPosition::First);
|
||||||
|
let sun = Slot::new(Day(12), ShiftPosition::First);
|
||||||
|
|
||||||
let sat = Slot::new(Day(7), ShiftPosition::First);
|
assert!(!tracker.reached_holiday_limit(&bounds, &r_id));
|
||||||
let sun = Slot::new(Day(8), ShiftPosition::First);
|
tracker.insert(r_id, &minimal_config, sat);
|
||||||
|
assert!(tracker.reached_holiday_limit(&bounds, &r_id));
|
||||||
tracker.insert(res_id, &config, sat);
|
tracker.insert(r_id, &minimal_config, sun);
|
||||||
assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id));
|
assert!(tracker.reached_holiday_limit(&bounds, &r_id));
|
||||||
|
|
||||||
tracker.insert(res_id, &config, sun);
|
|
||||||
assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) {
|
fn test_reached_shift_type_limit(minimal_config: UserConfig) {
|
||||||
let res_id = ResidentId(1);
|
let mut tracker = WorkloadTracker::default();
|
||||||
let slot = Slot::new(Day(1), ShiftPosition::First);
|
let r_id = ResidentId(1);
|
||||||
|
let mut bounds = WorkloadBounds::default();
|
||||||
|
bounds
|
||||||
|
.max_by_shift_type
|
||||||
|
.insert((r_id, ShiftType::OpenFirst), 1);
|
||||||
|
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
|
||||||
|
let slot_2 = Slot::new(Day(3), ShiftPosition::First);
|
||||||
|
let open_first = ShiftType::OpenFirst;
|
||||||
|
|
||||||
tracker.insert(res_id, &config, slot);
|
assert!(!tracker.reached_shift_type_limit(&bounds, &r_id, open_first));
|
||||||
assert_eq!(tracker.current_workload(&res_id), 1);
|
tracker.insert(r_id, &minimal_config, slot_1);
|
||||||
|
assert!(tracker.reached_shift_type_limit(&bounds, &r_id, open_first));
|
||||||
|
tracker.insert(r_id, &minimal_config, slot_2);
|
||||||
|
assert!(tracker.reached_shift_type_limit(&bounds, &r_id, open_first));
|
||||||
|
}
|
||||||
|
|
||||||
tracker.remove(res_id, &config, slot);
|
#[rstest]
|
||||||
assert_eq!(tracker.current_workload(&res_id), 0);
|
fn test_backtracking_state(minimal_config: UserConfig) {
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let r_id = ResidentId(1);
|
||||||
|
let sat = Slot::new(Day(11), ShiftPosition::First);
|
||||||
|
let sun = Slot::new(Day(12), ShiftPosition::First);
|
||||||
|
let open_first = ShiftType::OpenFirst;
|
||||||
|
let closed = ShiftType::Closed;
|
||||||
|
|
||||||
|
tracker.insert(r_id, &minimal_config, sat);
|
||||||
|
assert_eq!(1, tracker.current_workload(&r_id));
|
||||||
|
assert_eq!(1, tracker.current_holiday_workload(&r_id));
|
||||||
|
assert_eq!(1, tracker.current_shift_type_workload(&r_id, open_first));
|
||||||
|
tracker.insert(r_id, &minimal_config, sun);
|
||||||
|
assert_eq!(2, tracker.current_workload(&r_id));
|
||||||
|
assert_eq!(2, tracker.current_holiday_workload(&r_id));
|
||||||
|
assert_eq!(1, tracker.current_shift_type_workload(&r_id, open_first));
|
||||||
|
assert_eq!(1, tracker.current_shift_type_workload(&r_id, closed));
|
||||||
|
|
||||||
|
tracker.remove(r_id, &minimal_config, sun);
|
||||||
|
assert_eq!(1, tracker.current_workload(&r_id));
|
||||||
|
assert_eq!(1, tracker.current_holiday_workload(&r_id));
|
||||||
|
assert_eq!(1, tracker.current_shift_type_workload(&r_id, open_first));
|
||||||
|
assert_eq!(0, tracker.current_shift_type_workload(&r_id, closed));
|
||||||
|
tracker.remove(r_id, &minimal_config, sat);
|
||||||
|
assert_eq!(0, tracker.current_workload(&r_id));
|
||||||
|
assert_eq!(0, tracker.current_holiday_workload(&r_id));
|
||||||
|
assert_eq!(0, tracker.current_shift_type_workload(&r_id, open_first));
|
||||||
|
assert_eq!(0, tracker.current_shift_type_workload(&r_id, closed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
src-tauri/tests/common/mod.rs
Normal file
58
src-tauri/tests/common/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use rota_lib::{
|
||||||
|
config::{ToxicPair, UserConfig},
|
||||||
|
schedule::MonthlySchedule,
|
||||||
|
slot::{Day, ShiftPosition, Slot},
|
||||||
|
workload::{WorkloadBounds, WorkloadTracker},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn validate_all_constraints(
|
||||||
|
schedule: &MonthlySchedule,
|
||||||
|
tracker: &WorkloadTracker,
|
||||||
|
config: &UserConfig,
|
||||||
|
) {
|
||||||
|
assert_eq!(schedule.0.len() as u8, config.total_slots);
|
||||||
|
|
||||||
|
for d in 2..=config.total_days {
|
||||||
|
let current: Vec<_> = [ShiftPosition::First, ShiftPosition::Second]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d), p)))
|
||||||
|
.collect();
|
||||||
|
let previous: Vec<_> = [ShiftPosition::First, ShiftPosition::Second]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d - 1), p)))
|
||||||
|
.collect();
|
||||||
|
for r in current {
|
||||||
|
assert!(!previous.contains(&r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for d in 1..=config.total_days {
|
||||||
|
let day = Day(d);
|
||||||
|
if day.is_open_shift() {
|
||||||
|
let r1 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::First));
|
||||||
|
let r2 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::Second));
|
||||||
|
assert_ne!(r1, r2);
|
||||||
|
if let (Some(id1), Some(id2)) = (r1, r2) {
|
||||||
|
let pair = ToxicPair::from((*id1, *id2));
|
||||||
|
assert!(config.toxic_pairs.iter().all(|t| !t.matches(&pair)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bounds = WorkloadBounds::new_with_config(config);
|
||||||
|
for (slot, r_id) in &schedule.0 {
|
||||||
|
let r = config
|
||||||
|
.residents
|
||||||
|
.iter()
|
||||||
|
.find(|r| &r.id == r_id)
|
||||||
|
.expect("Resident not found");
|
||||||
|
assert!(r.allowed_types.contains(&slot.shift_type()));
|
||||||
|
assert!(!r.negative_shifts.contains(&slot.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
for resident in &config.residents {
|
||||||
|
let workload = tracker.current_workload(&resident.id);
|
||||||
|
let max = *bounds.max_workloads.get(&resident.id).unwrap();
|
||||||
|
assert!(workload <= max, "workload: {}, max: {}", workload, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,118 +1,63 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod integration_tests {
|
mod integration_tests {
|
||||||
|
use crate::common::validate_all_constraints;
|
||||||
use rota_lib::{
|
use rota_lib::{
|
||||||
config::{ToxicPair, UserConfig},
|
config::UserConfig,
|
||||||
resident::Resident,
|
fixtures::{
|
||||||
schedule::{MonthlySchedule, ShiftType},
|
complex_config, hard_config, manual_shifts_heavy_config, maximal_config, minimal_config,
|
||||||
|
},
|
||||||
|
schedule::MonthlySchedule,
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
slot::{Day, ShiftPosition, Slot},
|
workload::WorkloadTracker,
|
||||||
workload::{WorkloadBounds, WorkloadTracker},
|
|
||||||
};
|
};
|
||||||
use rstest::{fixture, rstest};
|
use rstest::rstest;
|
||||||
|
|
||||||
#[fixture]
|
#[ctor::ctor]
|
||||||
fn minimal_config() -> UserConfig {
|
fn global_setup() {
|
||||||
UserConfig::default().with_residents(vec![
|
env_logger::builder()
|
||||||
Resident::new(1, "R1"),
|
.filter_level(log::LevelFilter::Info)
|
||||||
Resident::new(2, "R2"),
|
.init();
|
||||||
Resident::new(3, "R3"),
|
|
||||||
Resident::new(4, "R4"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn maximal_config() -> UserConfig {
|
|
||||||
UserConfig::default()
|
|
||||||
.with_holidays(vec![2, 3, 10, 11, 12, 25])
|
|
||||||
.with_residents(vec![
|
|
||||||
Resident::new(1, "R1").with_max_shifts(3),
|
|
||||||
Resident::new(2, "R2").with_max_shifts(4),
|
|
||||||
Resident::new(3, "R3").with_reduced_load(),
|
|
||||||
Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
|
|
||||||
Resident::new(5, "R5")
|
|
||||||
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
|
|
||||||
Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]),
|
|
||||||
Resident::new(7, "R7"),
|
|
||||||
Resident::new(8, "R8"),
|
|
||||||
Resident::new(9, "R9"),
|
|
||||||
Resident::new(10, "R10"),
|
|
||||||
])
|
|
||||||
.with_toxic_pairs(vec![
|
|
||||||
ToxicPair::new(1, 2),
|
|
||||||
ToxicPair::new(3, 4),
|
|
||||||
ToxicPair::new(7, 8),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn manual_shifts_heavy_config() -> UserConfig {
|
|
||||||
UserConfig::default().with_residents(vec![
|
|
||||||
Resident::new(1, "R1").with_manual_shifts(vec![
|
|
||||||
Slot::new(Day(1), ShiftPosition::First),
|
|
||||||
Slot::new(Day(3), ShiftPosition::First),
|
|
||||||
Slot::new(Day(5), ShiftPosition::Second),
|
|
||||||
]),
|
|
||||||
Resident::new(2, "R2").with_manual_shifts(vec![
|
|
||||||
Slot::new(Day(2), ShiftPosition::First),
|
|
||||||
Slot::new(Day(4), ShiftPosition::First),
|
|
||||||
]),
|
|
||||||
Resident::new(3, "R3"),
|
|
||||||
Resident::new(4, "R4"),
|
|
||||||
Resident::new(5, "R5"),
|
|
||||||
Resident::new(6, "R6"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[fixture]
|
|
||||||
fn complex_config() -> UserConfig {
|
|
||||||
UserConfig::default()
|
|
||||||
.with_holidays(vec![5, 12, 19, 26])
|
|
||||||
.with_residents(vec![
|
|
||||||
Resident::new(1, "R1")
|
|
||||||
.with_max_shifts(3)
|
|
||||||
.with_negative_shifts(vec![Day(1), Day(2), Day(3)]),
|
|
||||||
Resident::new(2, "R2")
|
|
||||||
.with_max_shifts(3)
|
|
||||||
.with_negative_shifts(vec![Day(4), Day(5), Day(6)]),
|
|
||||||
Resident::new(3, "R3")
|
|
||||||
.with_max_shifts(3)
|
|
||||||
.with_negative_shifts(vec![Day(7), Day(8), Day(9)]),
|
|
||||||
Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
|
|
||||||
Resident::new(5, "R5")
|
|
||||||
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
|
|
||||||
Resident::new(6, "R6"),
|
|
||||||
Resident::new(7, "R7"),
|
|
||||||
Resident::new(8, "R8"),
|
|
||||||
])
|
|
||||||
.with_toxic_pairs(vec![
|
|
||||||
ToxicPair::new(1, 2),
|
|
||||||
ToxicPair::new(2, 3),
|
|
||||||
ToxicPair::new(5, 6),
|
|
||||||
ToxicPair::new(6, 7),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_minimal_config(minimal_config: UserConfig) -> anyhow::Result<()> {
|
fn test_minimal_config(
|
||||||
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
|
mut minimal_config: UserConfig,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
minimal_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
|
||||||
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&minimal_config, &tracker))
|
||||||
|
);
|
||||||
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_maximal_config(maximal_config: UserConfig) -> anyhow::Result<()> {
|
fn test_maximal_config(
|
||||||
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
|
mut maximal_config: UserConfig,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
maximal_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(maximal_config.clone());
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
let scheduler = Scheduler::new_with_config(maximal_config.clone());
|
|
||||||
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&maximal_config, &tracker))
|
||||||
|
);
|
||||||
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &maximal_config);
|
validate_all_constraints(&schedule, &tracker, &maximal_config);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -120,81 +65,80 @@ mod integration_tests {
|
|||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_manual_shifts_heavy_config(
|
fn test_manual_shifts_heavy_config(
|
||||||
manual_shifts_heavy_config: UserConfig,
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
|
mut manual_shifts_heavy_config: UserConfig,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
manual_shifts_heavy_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(manual_shifts_heavy_config.clone());
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
let scheduler = Scheduler::new_with_config(manual_shifts_heavy_config.clone());
|
|
||||||
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&manual_shifts_heavy_config, &tracker))
|
||||||
|
);
|
||||||
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config);
|
validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_complex_config(complex_config: UserConfig) -> anyhow::Result<()> {
|
fn test_complex_config(
|
||||||
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
|
mut complex_config: UserConfig,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
complex_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(complex_config.clone());
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
let scheduler = Scheduler::new_with_config(complex_config.clone());
|
|
||||||
|
|
||||||
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
|
||||||
|
|
||||||
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&complex_config, &tracker))
|
||||||
|
);
|
||||||
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &complex_config);
|
validate_all_constraints(&schedule, &tracker, &complex_config);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_all_constraints(
|
#[rstest]
|
||||||
schedule: &MonthlySchedule,
|
fn test_hard_config(
|
||||||
tracker: &WorkloadTracker,
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
config: &UserConfig,
|
mut hard_config: UserConfig,
|
||||||
) {
|
) -> anyhow::Result<()> {
|
||||||
assert_eq!(schedule.0.len() as u8, config.total_slots);
|
hard_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(hard_config.clone());
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
for d in 2..=config.total_days {
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
let current: Vec<_> = [ShiftPosition::First, ShiftPosition::Second]
|
println!(
|
||||||
.iter()
|
"{}",
|
||||||
.filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d), p)))
|
schedule.report(&schedule.metrics(&hard_config, &tracker))
|
||||||
.collect();
|
);
|
||||||
let previous: Vec<_> = [ShiftPosition::First, ShiftPosition::Second]
|
assert!(solved);
|
||||||
.iter()
|
validate_all_constraints(&schedule, &tracker, &hard_config);
|
||||||
.filter_map(|&p| schedule.get_resident_id(&Slot::new(Day(d - 1), p)))
|
|
||||||
.collect();
|
Ok(())
|
||||||
for res in current {
|
|
||||||
assert!(!previous.contains(&res));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for d in 1..=config.total_days {
|
#[rstest]
|
||||||
let day = Day(d);
|
fn test_export_pipeline(minimal_config: UserConfig) -> anyhow::Result<()> {
|
||||||
if day.is_open_shift() {
|
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||||
let r1 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::First));
|
let mut schedule = MonthlySchedule::new();
|
||||||
let r2 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::Second));
|
let mut tracker = WorkloadTracker::default();
|
||||||
assert_ne!(r1, r2);
|
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
||||||
if let (Some(id1), Some(id2)) = (r1, r2) {
|
|
||||||
let pair = ToxicPair::from((*id1, *id2));
|
|
||||||
assert!(config.toxic_pairs.iter().all(|t| !t.matches(&pair)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let bounds = WorkloadBounds::new_with_config(config);
|
schedule.export_as_docx(&minimal_config)?;
|
||||||
for (slot, res_id) in &schedule.0 {
|
|
||||||
let res = config
|
|
||||||
.residents
|
|
||||||
.iter()
|
|
||||||
.find(|r| &r.id == res_id)
|
|
||||||
.expect("Resident not found");
|
|
||||||
assert!(res.allowed_types.contains(&slot.shift_type()));
|
|
||||||
assert!(!res.negative_shifts.contains(&slot.day));
|
|
||||||
}
|
|
||||||
|
|
||||||
for resident in &config.residents {
|
let metadata = std::fs::metadata("rota.docx")?;
|
||||||
let workload = tracker.current_workload(&resident.id);
|
assert!(metadata.len() > 0);
|
||||||
let max = *bounds.max_workloads.get(&resident.id).unwrap();
|
std::fs::remove_file("rota.docx")?;
|
||||||
assert!(workload <= max);
|
|
||||||
}
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,4 +118,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,38 +2,38 @@
|
|||||||
import Basic from "./components/configurations/basic.svelte";
|
import Basic from "./components/configurations/basic.svelte";
|
||||||
import Residents from "./components/configurations/residents.svelte";
|
import Residents from "./components/configurations/residents.svelte";
|
||||||
import Advanced from "./components/configurations/advanced.svelte";
|
import Advanced from "./components/configurations/advanced.svelte";
|
||||||
import Preview from "./components/schedule/preview.svelte";
|
import { EngineStatus, rota, steps } from "./state.svelte.js";
|
||||||
|
|
||||||
import { rota, steps } from "./state.svelte.js";
|
|
||||||
import Generate from "./components/schedule/generate.svelte";
|
import Generate from "./components/schedule/generate.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="grid h-screen w-full grid-cols-5 overflow-hidden bg-zinc-200/50 font-sans tracking-tight antialiased"
|
class="bg-zinc-200/ grid h-screen w-full grid-cols-5 overflow-hidden font-sans tracking-tight antialiased"
|
||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
class="col-span-1 flex flex-col border-r border-zinc-200 bg-zinc-50/50 font-sans antialiased"
|
class="col-span-1 flex flex-col border-r border-zinc-200 bg-zinc-50/50 font-sans antialiased"
|
||||||
>
|
>
|
||||||
<div class="flex justify-center border p-3 font-sans">
|
<div class="flex justify-center p-2 font-sans">
|
||||||
<h1 class="text-xl font-black tracking-tight uppercase">Rota Scheduler</h1>
|
<h1 class="text-xl font-black tracking-tighter text-slate-900 uppercase">
|
||||||
|
Rota <span class="text-emerald-600">Scheduler</span>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-px w-full bg-zinc-200"></div>
|
||||||
|
|
||||||
<nav class="relative flex-1 p-6">
|
<nav class="relative flex flex-col p-4">
|
||||||
<div class="absolute top-10 bottom-10 left-9.75 w-0.5 bg-zinc-200"></div>
|
<div class="flex flex-col gap-2">
|
||||||
|
|
||||||
<div class="relative flex h-full flex-col justify-between">
|
|
||||||
{#each steps as step}
|
{#each steps as step}
|
||||||
<button
|
<button
|
||||||
onclick={() => (rota.currentStep = step.id)}
|
onclick={() => (rota.currentStep = step.id)}
|
||||||
class="group relative z-10 flex items-center gap-4 py-4 transition-all"
|
class="group relative z-10 flex items-center gap-4 rounded-xl p-4 transition-all
|
||||||
|
{rota.currentStep === step.id ? 'bg-zinc-200/80 shadow-inner' : 'hover:bg-zinc-100/50'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300
|
class="flex size-8 items-center justify-center rounded-lg transition-all duration-300
|
||||||
{rota.currentStep === step.id
|
{rota.currentStep === step.id
|
||||||
? 'bg-slate-800 text-white'
|
? 'bg-slate-800 text-white shadow-md'
|
||||||
: rota.currentStep > step.id
|
: rota.currentStep > step.id
|
||||||
? 'bg-emerald-600 text-white'
|
? 'bg-emerald-600 text-white'
|
||||||
: 'bg-white text-zinc-400'}"
|
: 'border border-zinc-200 bg-white text-zinc-400'}"
|
||||||
>
|
>
|
||||||
{#if rota.currentStep > step.id}
|
{#if rota.currentStep > step.id}
|
||||||
<svg
|
<svg
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="flex flex-col items-start">
|
<div class="flex flex-col items-start">
|
||||||
<span
|
<span
|
||||||
class="text-[10px] font-bold tracking-widest uppercase
|
class="text-[10px] font-bold tracking-widest uppercase
|
||||||
{rota.currentStep === step.id ? 'text-black-800' : 'text-zinc-400'}"
|
{rota.currentStep === step.id ? 'text-zinc-600' : 'text-zinc-400'}"
|
||||||
>
|
>
|
||||||
ΒΗΜΑ {step.id}
|
ΒΗΜΑ {step.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -70,19 +70,130 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="border-t border-zinc-200 bg-white p-6">
|
{#if rota.metrics.length > 0}
|
||||||
<div class="rounded-xl border border-zinc-100 bg-zinc-50 p-4">
|
<div class="h-px w-full bg-zinc-200"></div>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="flex flex-col py-4">
|
||||||
<span class="text-[10px] font-bold text-zinc-500 uppercase">ΟΛΟΚΛΗΡΩΣΗ</span>
|
<p class="px-6 pb-2 text-[10px] font-black tracking-widest text-zinc-400 uppercase">
|
||||||
<span class="text-[10px] font-bold text-zinc-500"
|
Δικαιωσυνη
|
||||||
>{(((rota.currentStep - 1) / (steps.length - 1)) * 100).toFixed(0)}%</span
|
</p>
|
||||||
|
<div class="w-full overflow-hidden px-2 text-[10px]">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-zinc-300 bg-zinc-50">
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-2 py-1 text-left font-bold text-zinc-500 uppercase last:border-r-0"
|
||||||
|
>Ειδικευομενος</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>ΣΥΝ</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Α1</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Α2</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Κ</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>ΣΚ/Α</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rota.metrics as m}
|
||||||
|
<tr class="border-b border-zinc-200 last:border-0 hover:bg-zinc-50">
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-2 py-1 font-medium text-zinc-700 last:border-r-0"
|
||||||
|
>{m.name}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center font-bold text-zinc-800 last:border-r-0"
|
||||||
|
>{m.total}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.open_first}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.open_second}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.closed}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.holiday}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="border-t border-zinc-300 bg-zinc-50">
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-2 py-1 text-left font-bold text-zinc-500 uppercase last:border-r-0"
|
||||||
|
>Συνολο</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.total, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.open_first, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.open_second, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.closed, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.holiday, 0)}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-auto h-px w-full bg-zinc-200"></div>
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<div
|
||||||
|
class="border-l-2 py-2 pl-3 transition-colors
|
||||||
|
{rota.engineStatus === EngineStatus.Running
|
||||||
|
? 'border-amber-400'
|
||||||
|
: rota.engineStatus === EngineStatus.Success
|
||||||
|
? 'border-emerald-500'
|
||||||
|
: rota.engineStatus === EngineStatus.Error
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-zinc-300'}"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="text-[10px] font-black tracking-widest text-zinc-400 uppercase"
|
||||||
|
>ΚΑΤΑΣΤΑΣΗ ΜΗΧΑΝΗΣ</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200">
|
|
||||||
<div
|
<div class="flex flex-col gap-1">
|
||||||
class="h-full bg-emerald-600 transition-all duration-500"
|
<span class="text-[11px] font-bold text-zinc-800 uppercase">
|
||||||
style="width: {(((rota.currentStep - 1) / (steps.length - 1)) * 100).toFixed(0)}%"
|
{rota.engineStatus}
|
||||||
></div>
|
</span>
|
||||||
|
<p class="text-[10px] leading-tight font-medium text-zinc-500">
|
||||||
|
{rota.lastMessage}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,8 +208,6 @@
|
|||||||
{:else if rota.currentStep === 3}
|
{:else if rota.currentStep === 3}
|
||||||
<Advanced />
|
<Advanced />
|
||||||
{:else if rota.currentStep === 4}
|
{:else if rota.currentStep === 4}
|
||||||
<Preview />
|
|
||||||
{:else if rota.currentStep === 5}
|
|
||||||
<Generate />
|
<Generate />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,73 +29,57 @@
|
|||||||
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if rota.forbiddenPairs.length > 0}
|
<div class="space-y-2">
|
||||||
<div class="space-y-3">
|
|
||||||
{#each rota.forbiddenPairs as pair, i (pair)}
|
{#each rota.forbiddenPairs as pair, i (pair)}
|
||||||
<div
|
<div class="flex items-center gap-2">
|
||||||
class="group flex items-center gap-4 rounded-2xl border border-zinc-200 bg-white p-4 transition-all hover:border-blue-200 hover:bg-white"
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
|
||||||
Resident A
|
|
||||||
</p>
|
|
||||||
<select
|
<select
|
||||||
bind:value={pair.id1}
|
bind:value={pair.id1}
|
||||||
class="w-full rounded-xl border border-zinc-100 bg-zinc-50 px-3 py-2 text-sm font-semibold text-zinc-700 outline-none focus:ring-2"
|
class="flex-1 appearance-none rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
>
|
>
|
||||||
{#each rota.residents as r}
|
{#each rota.residents as r}
|
||||||
<option value={r.id}>{r.name || "Unnamed Resident"}</option>
|
<option value={r.id}>{r.name || "Ανώνυμος"}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<span class="text-sm font-bold text-zinc-400">×</span>
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
|
||||||
Resident B
|
|
||||||
</p>
|
|
||||||
<select
|
<select
|
||||||
bind:value={pair.id2}
|
bind:value={pair.id2}
|
||||||
class="w-full rounded-xl border border-zinc-100 bg-zinc-50 px-3 py-2 text-sm font-semibold text-zinc-700 outline-none focus:ring-2"
|
class="flex-1 appearance-none rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
>
|
>
|
||||||
{#each rota.residents as r}
|
{#each rota.residents as r}
|
||||||
{#if r.id !== pair.id1}
|
{#if r.id !== pair.id1}
|
||||||
<option value={r.id}>{r.name || "Unnamed Resident"}</option>
|
<option value={r.id}>{r.name || "Ανώνυμος"}</option>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => rota.removeForbiddenPair(i)}
|
onclick={() => rota.removeForbiddenPair(i)}
|
||||||
class="mt-5 rounded-lg p-2 text-zinc-300 transition-colors"
|
class="rounded-lg p-1.5 text-zinc-400 transition-colors hover:text-red-500 active:scale-90"
|
||||||
aria-label="Remove forbidden pair"
|
aria-label="Remove forbidden pair"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="18"
|
width="16"
|
||||||
height="18"
|
height="16"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2.5"
|
stroke-width="2.5"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path
|
|
||||||
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
|
||||||
/><line x1="10" x2="10" y1="11" y2="17" /><line
|
|
||||||
x1="14"
|
|
||||||
x2="14"
|
|
||||||
y1="11"
|
|
||||||
y2="17"
|
|
||||||
/></svg
|
|
||||||
>
|
>
|
||||||
|
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path
|
||||||
|
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||||
|
/><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-8 flex justify-center">
|
<div class="mt-6 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
onclick={() => rota.addForbiddenPair()}
|
onclick={() => rota.addForbiddenPair()}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
{ value: 6, label: "Ιούνιος" },
|
{ value: 6, label: "Ιούνιος" },
|
||||||
{ value: 7, label: "Ιούλιος" },
|
{ value: 7, label: "Ιούλιος" },
|
||||||
{ value: 8, label: "Αύγουστος" },
|
{ value: 8, label: "Αύγουστος" },
|
||||||
{ value: 9, label: "Σεπτέμβιος" },
|
{ value: 9, label: "Σεπτέμβριος" },
|
||||||
{ value: 10, label: "Οκτώβριος" },
|
{ value: 10, label: "Οκτώβριος" },
|
||||||
{ value: 11, label: "Νοέμβριος" },
|
{ value: 11, label: "Νοέμβριος" },
|
||||||
{ value: 12, label: "Δεκέμβιος" }
|
{ value: 12, label: "Δεκέμβριος" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const yearOptions = [2026, 2027];
|
const yearOptions = [2026, 2027];
|
||||||
@@ -40,11 +40,10 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">MONTH</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΜΗΝΑΣ</p>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select
|
<select
|
||||||
bind:value={rota.selectedMonth}
|
bind:value={rota.selectedMonth}
|
||||||
onchange={() => rota.syncProjectMonth()}
|
|
||||||
class="w-full appearance-none rounded-xl border border-zinc-200 bg-white px-4 py-3 font-semibold text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
class="w-full appearance-none rounded-xl border border-zinc-200 bg-white px-4 py-3 font-semibold text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
>
|
>
|
||||||
{#each monthOptions as month}
|
{#each monthOptions as month}
|
||||||
@@ -68,11 +67,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">YEAR</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΧΡΟΝΟΣ</p>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select
|
<select
|
||||||
bind:value={rota.selectedYear}
|
bind:value={rota.selectedYear}
|
||||||
onchange={() => rota.syncProjectMonth()}
|
|
||||||
class="w-full appearance-none rounded-xl border border-zinc-200 bg-white px-4 py-3 font-semibold text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
class="w-full appearance-none rounded-xl border border-zinc-200 bg-white px-4 py-3 font-semibold text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
>
|
>
|
||||||
{#each yearOptions as year}
|
{#each yearOptions as year}
|
||||||
@@ -97,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 space-y-2">
|
<div class="mt-8 space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">HOLIDAYS</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΑΡΓΙΕΣ</p>
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
|
|||||||
@@ -33,17 +33,17 @@
|
|||||||
</header>
|
</header>
|
||||||
{#each rota.residents as resident, residentIndex (resident.id)}
|
{#each rota.residents as resident, residentIndex (resident.id)}
|
||||||
<div
|
<div
|
||||||
class="group relative flex flex-col gap-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-6 transition-all hover:border-blue-200 hover:bg-white"
|
class="group relative flex flex-col gap-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-6 transition-all hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
<span class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
Resident {residentIndex + 1}
|
ΕΙΔΙΚΕΥΟΜΕΝΟΣ {residentIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => rota.removeResident(resident.id)}
|
onclick={() => rota.removeResident(resident.id)}
|
||||||
class="text-zinc-500 transition-colors"
|
class="text-zinc-400 transition-colors hover:text-red-500 active:scale-90 active:text-red-700"
|
||||||
aria-label="Remove resident"
|
aria-label="Remove resident"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -65,23 +65,23 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-12 items-end gap-6">
|
<div class="grid grid-cols-12 items-end gap-6">
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Full Name</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΟΝΟΜΑ</p>
|
||||||
<input
|
<input
|
||||||
bind:value={resident.name}
|
bind:value={resident.name}
|
||||||
placeholder="π.χ. Τάκης Τσουκαλάς"
|
placeholder="π.χ. Τάκης Τσουκαλάς"
|
||||||
class="w-full rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm outline-none hover:border-blue-200 hover:bg-white"
|
class="w-full rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm outline-none hover:border-zinc-300 hover:bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Negative Shifts</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΑΡΝΗΤΙΚΕΣ ΕΦΗΜΕΡΙΕΣ</p>
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-blue-200 hover:bg-white"
|
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2 text-zinc-500"
|
<span class="mr-2 text-zinc-500"
|
||||||
><svg
|
><svg
|
||||||
@@ -190,14 +190,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Manual Shifts</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΑΝΑΓΚΑΣΤΙΚΕΣ ΕΦΗΜΕΡΙΕΣ</p>
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-blue-200 hover:bg-white"
|
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2 text-zinc-500"
|
<span class="mr-2 text-zinc-500"
|
||||||
><svg
|
><svg
|
||||||
@@ -307,22 +307,23 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-3 divide-x divide-zinc-200 rounded-xl border border-zinc-200 bg-white py-3"
|
class="grid grid-cols-3 divide-x divide-zinc-200 rounded-xl border border-zinc-200 bg-white py-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center gap-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Max Shifts</p>
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
<div class="flex items-center gap-2">
|
ΜΕΓΙΣΤΟΣ ΦΟΡΤΟΣ
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={resident.maxShifts}
|
bind:value={resident.maxShifts}
|
||||||
placeholder="-"
|
class="w-8 rounded border border-zinc-200 bg-white py-1 text-center text-sm font-bold text-zinc-700 transition-all outline-none hover:border-zinc-300 focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
class="w-16 rounded-lg border border-zinc-100 bg-zinc-50 py-1 text-center text-sm font-bold text-zinc-700 outline-none placeholder:text-zinc-300"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center space-y-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Shift Types</p>
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
|
ΤΥΠΟΙ ΕΦΗΜΕΡΙΩΝ
|
||||||
|
</p>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#each ["Closed", "OpenFirst", "OpenSecond"] as type}
|
{#each [["Closed", "Κλειστή"], ["OpenFirst", "Ανοιχτή 1"], ["OpenSecond", "Ανοιχτή 2"]] as [type, label]}
|
||||||
{@const active = resident.allowedTypes.includes(type)}
|
{@const active = resident.allowedTypes.includes(type)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -338,29 +339,27 @@
|
|||||||
? 'border-zinc-800 bg-zinc-800 text-white'
|
? 'border-zinc-800 bg-zinc-800 text-white'
|
||||||
: 'border-zinc-200 bg-white text-zinc-500 hover:bg-zinc-50 hover:text-zinc-600'}"
|
: 'border-zinc-200 bg-white text-zinc-500 hover:bg-zinc-50 hover:text-zinc-600'}"
|
||||||
>
|
>
|
||||||
{type.replace("OpenAs", "")}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center gap-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
Reduced Workload
|
ΜΕΙΩΣΗ ΦΟΡΤΟΥ
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={resident.reducedLoad}
|
||||||
onclick={() => (resident.reducedLoad = !resident.reducedLoad)}
|
onclick={() => (resident.reducedLoad = !resident.reducedLoad)}
|
||||||
class="flex items-center gap-2 rounded-lg border px-3 py-1 transition-all hover:border-blue-200 hover:bg-white
|
class="relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200
|
||||||
{resident.reducedLoad
|
{resident.reducedLoad ? 'bg-green-500' : 'bg-zinc-200'}"
|
||||||
? 'border-green-200 bg-green-50 text-green-700'
|
|
||||||
: 'border-zinc-200 bg-white text-zinc-500'}"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="size-2 rounded-full {resident.reducedLoad
|
class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform duration-200
|
||||||
? 'animate-pulse bg-green-500'
|
{resident.reducedLoad ? 'translate-x-4' : 'translate-x-0'}"
|
||||||
: 'bg-zinc-300'}"
|
|
||||||
></div>
|
></div>
|
||||||
<span class="text-[10px] font-bold uppercase">-1</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +370,8 @@
|
|||||||
<Button
|
<Button
|
||||||
onclick={() => rota.addResident()}
|
onclick={() => rota.addResident()}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
disabled={rota.residents.some((r) => !r.name.trim())}
|
||||||
|
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Προσθήκη Ειδικευόμενου
|
Προσθήκη Ειδικευόμενου
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type MonthlyScheduleDTO, rota, steps, type ShiftPosition } from "../../state.svelte.js";
|
import {
|
||||||
|
EngineStatus,
|
||||||
|
type MonthlyScheduleDTO,
|
||||||
|
type ResidentMetrics,
|
||||||
|
rota,
|
||||||
|
steps,
|
||||||
|
type ShiftPosition
|
||||||
|
} from "../../state.svelte.js";
|
||||||
|
|
||||||
function getResidentName(day: number, pos: ShiftPosition) {
|
function getResidentName(day: number, pos: ShiftPosition) {
|
||||||
const residentId = rota.solution[`${day}-${pos}`];
|
const residentId = rota.solution[`${day}-${pos}`];
|
||||||
@@ -41,23 +48,25 @@
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
let config = rota.toDTO();
|
let config = rota.toDTO();
|
||||||
console.log(config);
|
rota.engineStatus = EngineStatus.Running;
|
||||||
|
rota.lastMessage = "";
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let schedule = await invoke<MonthlyScheduleDTO>("generate", { config });
|
rota.solution = await invoke<MonthlyScheduleDTO>("generate", { config });
|
||||||
console.log(schedule);
|
rota.metrics = await invoke<ResidentMetrics[]>("get_metrics");
|
||||||
rota.solution = schedule;
|
rota.engineStatus = EngineStatus.Success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { kind, details } = error as AppError;
|
const { kind, details } = error as AppError;
|
||||||
|
rota.engineStatus = EngineStatus.Error;
|
||||||
|
rota.lastMessage = details;
|
||||||
console.error(`[${kind}] - ${details}`);
|
console.error(`[${kind}] - ${details}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function export_file() {
|
async function export_file() {
|
||||||
let schedule = rota.solution;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("export", { schedule });
|
await invoke("export");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { kind, details } = error as AppError;
|
const { kind, details } = error as AppError;
|
||||||
console.error(`[${kind}] - ${details}`);
|
console.error(`[${kind}] - ${details}`);
|
||||||
@@ -91,10 +100,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
||||||
{#each rota.emptySlots as _}<div class="bg-zinc-50/30"></div>{/each}
|
{#each rota.emptySlots as _}<div class="min-h-25 bg-zinc-100/60"></div>{/each}
|
||||||
{#each rota.daysArray as day (day)}
|
{#each rota.daysArray as day (day)}
|
||||||
{@const slotCount = getRequiredSlots(day)}
|
{@const slotCount = getRequiredSlots(day)}
|
||||||
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-blue-50/30">
|
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-teal-50/30">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
<span class="text-xs font-black text-zinc-500">{day}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +112,7 @@
|
|||||||
{#if slotCount > 0}
|
{#if slotCount > 0}
|
||||||
<button
|
<button
|
||||||
onclick={() => "handleCellClick(day, 1)"}
|
onclick={() => "handleCellClick(day, 1)"}
|
||||||
class="w-full overflow-hidden rounded border border-pink-200 bg-pink-50 px-1.5 py-1 text-left text-[10px] font-bold text-pink-600 transition-colors hover:bg-pink-100"
|
class="w-full overflow-hidden rounded border border-rose-200 bg-rose-50 px-1.5 py-1 text-left text-[10px] font-bold text-rose-700 transition-colors hover:bg-rose-100"
|
||||||
>
|
>
|
||||||
{getResidentName(day, "First")}
|
{getResidentName(day, "First")}
|
||||||
</button>
|
</button>
|
||||||
@@ -111,7 +120,7 @@
|
|||||||
{#if slotCount > 1}
|
{#if slotCount > 1}
|
||||||
<button
|
<button
|
||||||
onclick={() => "handleCellClick(day, 2)"}
|
onclick={() => "handleCellClick(day, 2)"}
|
||||||
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-600 transition-colors hover:bg-emerald-100"
|
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-700 transition-colors hover:bg-emerald-100"
|
||||||
>
|
>
|
||||||
{getResidentName(day, "Second")}
|
{getResidentName(day, "Second")}
|
||||||
</button>
|
</button>
|
||||||
@@ -119,6 +128,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each Array.from( { length: (7 - ((rota.emptySlots.length + rota.daysArray.length) % 7)) % 7 } ) as _}
|
||||||
|
<div class="min-h-25 bg-zinc-100/60"></div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
|
||||||
import { rota, steps } from "../../state.svelte.js";
|
|
||||||
|
|
||||||
function getResidentName(day: number, slot: number) {
|
|
||||||
const assignedResidents = rota.residents.filter((resident) =>
|
|
||||||
resident.manualShifts.some(
|
|
||||||
(shift) =>
|
|
||||||
shift.day === day &&
|
|
||||||
shift.month === rota.selectedMonth &&
|
|
||||||
shift.year === rota.selectedYear
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const resident = assignedResidents[slot - 1];
|
|
||||||
return resident ? resident.name : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2 slots in odd days, 1 slot in even days
|
|
||||||
function getRequiredSlots(day: number) {
|
|
||||||
return day % 2 === 0 ? 1 : 2;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2>
|
|
||||||
<div class="justify-end">
|
|
||||||
<Button
|
|
||||||
onclick={() => (rota.currentStep -= 1)}
|
|
||||||
variant="outline"
|
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
|
||||||
>
|
|
||||||
Προηγούμενο
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onclick={() => (rota.currentStep += 1)}
|
|
||||||
variant="outline"
|
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
|
||||||
>
|
|
||||||
Επόμενο
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header class="mb-2">
|
|
||||||
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white">
|
|
||||||
<div class="grid grid-cols-7 border-b border-zinc-200 bg-zinc-50/50">
|
|
||||||
{#each ["ΔΕΥΤΕΡΑ", "ΤΡΙΤΗ", "ΤΕΤΑΡΤΗ", "ΠΕΜΠΤΗ", "ΠΑΡΑΣΚΕΥΗ", "ΣΑΒΒΑΤΟ", "ΚΥΡΙΑΚΗ"] as dayName}
|
|
||||||
<div class="py-2 text-center text-[10px] font-bold tracking-widest text-zinc-500 uppercase">
|
|
||||||
{dayName}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
|
||||||
{#each rota.emptySlots as _}<div class="bg-zinc-50/30"></div>{/each}
|
|
||||||
{#each rota.daysArray as day (day)}
|
|
||||||
{@const slotCount = getRequiredSlots(day)}
|
|
||||||
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-blue-50/30">
|
|
||||||
<div class="mb-2 flex items-center justify-between">
|
|
||||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#if slotCount > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => "handleCellClick(day, 1)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-pink-200 bg-pink-50 px-1.5 py-1 text-left text-[10px] font-bold text-pink-600 transition-colors hover:bg-pink-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, 1)}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if slotCount == 2}
|
|
||||||
<button
|
|
||||||
onclick={() => "handleCellClick(day, 2)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-600 transition-colors hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, 2)}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -16,6 +16,15 @@ export interface ForbiddenPair {
|
|||||||
id2: number;
|
id2: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResidentMetrics = {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
open_first: number;
|
||||||
|
open_second: number;
|
||||||
|
closed: number;
|
||||||
|
holiday: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class RotaState {
|
export class RotaState {
|
||||||
currentStep = $state(1);
|
currentStep = $state(1);
|
||||||
residentsCounter = $state(0);
|
residentsCounter = $state(0);
|
||||||
@@ -25,13 +34,14 @@ export class RotaState {
|
|||||||
holidays = $state<CalendarDate[]>([]);
|
holidays = $state<CalendarDate[]>([]);
|
||||||
forbiddenPairs = $state<ForbiddenPair[]>([]);
|
forbiddenPairs = $state<ForbiddenPair[]>([]);
|
||||||
|
|
||||||
projectMonth = $state(new CalendarDate(2026, 2, 1));
|
engineStatus = $state(EngineStatus.Idle);
|
||||||
|
lastMessage = $state("");
|
||||||
|
|
||||||
syncProjectMonth() {
|
metrics: ResidentMetrics[] = $state([]);
|
||||||
this.projectMonth = new CalendarDate(this.selectedYear, this.selectedMonth, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
projectMonth = $derived(new CalendarDate(this.selectedYear, this.selectedMonth, 1));
|
||||||
projectMonthDays = $derived(this.projectMonth.calendar.getDaysInMonth(this.projectMonth));
|
projectMonthDays = $derived(this.projectMonth.calendar.getDaysInMonth(this.projectMonth));
|
||||||
|
|
||||||
daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1));
|
daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1));
|
||||||
emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") }));
|
emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") }));
|
||||||
|
|
||||||
@@ -50,7 +60,9 @@ export class RotaState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeResident(id: number) {
|
removeResident(id: number) {
|
||||||
this.residents = this.residents.filter((r) => r.id !== id);
|
const index = this.residents.findIndex((r) => r.id === id);
|
||||||
|
if (index !== -1) this.residents.splice(index, 1);
|
||||||
|
this.forbiddenPairs = this.forbiddenPairs.filter((p) => p.id1 !== id && p.id2 !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
findResident(id: number) {
|
findResident(id: number) {
|
||||||
@@ -92,6 +104,13 @@ export class RotaState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum EngineStatus {
|
||||||
|
Idle = "ΣΕ ΑΝΑΜΟΝΗ",
|
||||||
|
Running = "Ο ΑΛΓΟΡΙΘΜΟΣ ΤΡΕΧΕΙ...",
|
||||||
|
Success = "ΕΠΙΤΥΧΗΣ ΔΗΜΙΟΥΡΓΙΑ",
|
||||||
|
Error = "ΣΦΑΛΜΑ ΣΥΣΤΗΜΑΤΟΣ"
|
||||||
|
}
|
||||||
|
|
||||||
export const rota = new RotaState();
|
export const rota = new RotaState();
|
||||||
|
|
||||||
export type MonthlyScheduleDTO = {
|
export type MonthlyScheduleDTO = {
|
||||||
@@ -141,11 +160,6 @@ export const steps = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Επισκόπηση",
|
|
||||||
description: "Έλεγξε το πρόγραμμα με τις υποχρεωτικές υπάρχουσες εφημερίες."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Δημιουργία",
|
title: "Δημιουργία",
|
||||||
description: "Τρέξε τον αλγόριθμο ανάθεσης εφημεριών, εξήγαγε τα αποτελέσματα."
|
description: "Τρέξε τον αλγόριθμο ανάθεσης εφημεριών, εξήγαγε τα αποτελέσματα."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user