Complete backtracking, restriction violations, split model.rs into multiple files

This commit is contained in:
2026-01-11 10:44:25 +02:00
parent e11e1376eb
commit 53f8695572
11 changed files with 998 additions and 489 deletions

30
src-tauri/Cargo.lock generated
View File

@@ -2926,6 +2926,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -2946,6 +2956,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -2964,6 +2984,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3143,6 +3172,7 @@ dependencies = [
"chrono",
"itertools",
"log",
"rand 0.9.2",
"rstest",
"serde",
"serde_json",

View File

@@ -27,3 +27,4 @@ itertools = "0.14.0"
rstest = "0.26.1"
tauri-plugin-log = "2"
log = "0.4.29"
rand = "0.9.2"

333
src-tauri/src/config.rs Normal file
View File

@@ -0,0 +1,333 @@
use std::collections::HashMap;
use chrono::Month;
use crate::{
resident::Resident,
schedule::{MonthlySchedule, ShiftType},
slot::{Day, ShiftPosition, Slot},
};
const YEAR: i32 = 2026;
#[derive(Debug)]
pub struct UserConfig {
month: Month,
year: i32,
pub holidays: Vec<usize>,
pub residents: Vec<Resident>,
pub toxic_pairs: Vec<(String, String)>,
// calculated from inputs
pub workload_limits: HashMap<String, u8>,
pub holiday_limits: HashMap<String, u8>,
}
impl UserConfig {
pub fn new(month: usize) -> Self {
Self {
month: Month::try_from(month as u8).unwrap(),
year: YEAR,
holidays: vec![],
residents: vec![],
toxic_pairs: vec![],
workload_limits: HashMap::new(),
holiday_limits: HashMap::new(),
}
}
pub fn with_holidays(mut self, holidays: Vec<usize>) -> Self {
self.holidays = holidays;
self
}
pub fn with_residents(mut self, residents: Vec<Resident>) -> Self {
self.residents = residents;
self
}
pub fn add(&mut self, resident: Resident) {
self.residents.push(resident);
}
pub fn with_toxic_pairs(mut self, toxic_pairs: Vec<(String, String)>) -> Self {
self.toxic_pairs = toxic_pairs;
self
}
pub fn total_days(&self) -> u8 {
self.month.num_days(self.year).unwrap()
}
pub fn total_slots(&self) -> u8 {
(1..=self.total_days())
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
.sum()
}
pub fn total_holiday_slots(&self) -> u8 {
(1..=self.total_days())
.filter(|&d| self.is_holiday_or_weekend_slot(d))
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
.sum()
}
pub fn is_holiday_or_weekend_slot(&self, day: u8) -> bool {
let day = Day(day);
day.is_weekend(self.month.number_from_month(), self.year)
|| self.holidays.contains(&(day.0 as usize))
}
/// 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_workload_limits(&mut self) {
let total_slots = self.total_slots();
let max_shifts_sum: usize = self
.residents
.iter()
.map(|r| r.max_shifts.unwrap_or(0))
.sum();
let residents_without_max_shifts: Vec<_> = self
.residents
.iter()
.filter(|r| r.max_shifts.is_none())
.collect();
let residents_without_max_shifts_size = residents_without_max_shifts.len();
if residents_without_max_shifts_size == 0 {
for r in &self.residents {
self.workload_limits
.insert(r.id.clone(), r.max_shifts.unwrap_or(0) as u8);
}
return;
}
// Untested scenario: Resident has manual max_shifts and also reduced workload flag
let total_reduced_loads: usize = residents_without_max_shifts
.iter()
.filter(|r| r.reduced_load)
.count();
let max_shifts_ceiling = (total_slots - max_shifts_sum as u8 + total_reduced_loads as u8)
.div_ceil(residents_without_max_shifts_size as u8);
for r in &self.residents {
let max_shifts = if let Some(manual_max_shifts) = r.max_shifts {
manual_max_shifts as u8
} else if r.reduced_load {
max_shifts_ceiling - 1
} else {
max_shifts_ceiling
};
self.workload_limits.insert(r.id.clone(), max_shifts);
}
}
pub fn calculate_holiday_limits(&mut self) {
let total_slots = self.total_slots();
let total_holiday_slots = self.total_holiday_slots();
for r in &self.residents {
let workload_limit = *self.workload_limits.get(&r.id).unwrap_or(&0);
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32;
let holiday_limit = share.ceil() as u8;
self.holiday_limits.insert(r.id.clone(), holiday_limit);
}
}
/// Return all possible candidates for the next slot
/// TODO: move this to another file, UserConfig should only hold the info set from GUI
///
/// @slot
/// @schedule
pub fn candidates(
&self,
slot: Slot,
schedule: &MonthlySchedule,
) -> Vec<(&Resident, &ShiftType)> {
let mut candidates = vec![];
let is_open = slot.is_open_shift();
let other_position = match slot.position {
ShiftPosition::First => ShiftPosition::Second,
ShiftPosition::Second => ShiftPosition::First,
};
let other_slot = Slot::new(slot.day, other_position);
let already_on_duty = schedule.get_resident_id(&other_slot);
for resident in &self.residents {
if let Some(on_duty_id) = &already_on_duty {
if &&resident.id == on_duty_id {
continue;
}
}
if resident.negative_shifts.contains(&slot.day) {
continue;
}
for shift_type in &resident.allowed_types {
match (shift_type, is_open, slot.position) {
(ShiftType::OpenFirst, true, ShiftPosition::First) => {
candidates.push((resident, shift_type))
}
(ShiftType::OpenSecond, true, ShiftPosition::Second) => {
candidates.push((resident, shift_type))
}
(ShiftType::Closed, false, _) => candidates.push((resident, shift_type)),
_ => continue,
}
}
}
candidates
}
pub fn default() -> Self {
Self {
month: Month::try_from(2).unwrap(),
year: YEAR,
holidays: vec![],
residents: vec![],
toxic_pairs: vec![],
workload_limits: HashMap::new(),
holiday_limits: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use chrono::Month;
use rstest::{fixture, rstest};
use crate::{
config::UserConfig,
resident::Resident,
schedule::{MonthlySchedule, ShiftType},
slot::{Day, ShiftPosition, Slot},
};
#[fixture]
fn setup() -> (UserConfig, MonthlySchedule) {
let mut config = UserConfig::default();
let res_a = Resident::new("1", "Stefanos");
let res_b = Resident::new("2", "Iordanis");
config.add(res_a);
config.add(res_b);
let schedule = MonthlySchedule::new();
(config, schedule)
}
#[rstest]
fn test_candidates_prevents_double_booking_on_open_day(setup: (UserConfig, MonthlySchedule)) {
let (config, mut schedule) = setup;
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let stefanos = &config.residents[0];
let iordanis = &config.residents[1];
schedule.insert(slot_1, stefanos);
let candidates = config.candidates(slot_2, &schedule);
let stefanos_is_candidate = candidates.iter().any(|(r, _)| r.id == stefanos.id);
assert!(!stefanos_is_candidate);
let iordanis_is_candidate = candidates.iter().any(|(r, _)| r.id == iordanis.id);
assert!(iordanis_is_candidate);
}
#[rstest]
fn test_candidates_respects_shift_type_position(setup: (UserConfig, MonthlySchedule)) {
let (config, schedule) = setup;
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let candidates = config.candidates(slot_1, &schedule);
for (_, shift_type) in candidates {
assert_eq!(shift_type, &ShiftType::OpenFirst);
}
}
#[rstest]
fn test_set_limits_fair_distribution() {
let mut config = UserConfig::new(1).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"),
]);
config.calculate_workload_limits();
assert_eq!(config.workload_limits["1"], 2);
assert_eq!(config.workload_limits["2"], 2);
assert_eq!(config.workload_limits["3"], 14);
assert_eq!(config.workload_limits["4"], 15);
assert_eq!(config.workload_limits["5"], 15);
}
#[rstest]
fn test_set_limits_complex_distribution() {
let mut config = UserConfig {
month: Month::January,
year: 2026,
holidays: vec![],
toxic_pairs: vec![],
workload_limits: HashMap::new(),
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"),
],
holiday_limits: HashMap::new(),
};
config.calculate_workload_limits();
assert_eq!(config.workload_limits["1"], 2);
assert_eq!(config.workload_limits["2"], 2);
assert_eq!(config.workload_limits["3"], 14);
assert_eq!(config.workload_limits["4"], 15);
assert_eq!(config.workload_limits["5"], 15);
}
#[rstest]
fn test_calculate_holiday_limits() {
let mut config = UserConfig::default();
let stefanos = Resident::new("1", "Stefanos");
let iordanis = Resident::new("2", "Iordanis");
config.residents = vec![stefanos, iordanis];
config.calculate_workload_limits();
config.calculate_holiday_limits();
let stefanos_limit = *config.holiday_limits.get("1").unwrap();
let iordanis_limit = *config.holiday_limits.get("2").unwrap();
assert_eq!(stefanos_limit, 6);
assert_eq!(iordanis_limit, 6);
}
#[rstest]
fn test_total_holiday_slots() {
let config = UserConfig::default().with_holidays(vec![2, 3, 4]);
assert_eq!(16, config.total_holiday_slots());
}
}

View File

@@ -1,6 +1,6 @@
// here lies the logic for the export of the final schedule into docx/pdf formats
use crate::model::{MonthlySchedule, WeeklySchedule};
use crate::schedule::MonthlySchedule;
#[derive(Debug)]
pub enum FileType {
@@ -54,12 +54,6 @@ impl MonthlySchedule {
}
}
impl Export for WeeklySchedule {
fn export(&self, file_type: FileType) {
todo!()
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;

View File

@@ -1,36 +0,0 @@
// list of algos/methods that make sure the state of the schedule is still fair
use crate::model::{Day, MonthlySchedule, Resident};
// return yes if any resident has a shift in back to bacak days
// TODO: performance: this could only check the current recursion resident otherwise we would have cut the branch earlier
pub fn back_to_back_shifts(schedule: &MonthlySchedule) -> bool {
schedule.contains_any_consecutive_shifts()
}
// return yes if the same resident has a shift on a sunday and also the next saturday
pub fn sunday_and_next_saturday_shifts(schedule: &MonthlySchedule, resident: &Resident) -> bool {
todo!()
}
// find if a pair exists doing a shift at the same day, if yes return true
pub fn pair_exists(
schedule: &MonthlySchedule,
resident_a: &Resident,
resident_b: &Resident,
) -> bool {
todo!()
}
// if day is odd then open otherwise closed
pub fn is_closed(day: &Day) -> bool {
todo!()
}
// here include:
// if total shifts are 50 and there are 5 residents BUT this resident has reduced workload, he should to 9 instead of 10
// if resident has specific shift types then see he is not put in a wrong shift type
// if resident has a max limit of shifts see that he is not doing more
pub fn are_personal_restrictions_met(resident: &Resident) -> bool {
todo!()
}

View File

@@ -1,44 +1,42 @@
//! Generator
//!
//! here lies the schedule generator which uses a simple backtracking algorithm
use rand::Rng;
use crate::model::{Configurations, Day, MonthlySchedule, Slot};
use crate::{config::UserConfig, schedule::MonthlySchedule, slot::Slot};
/// https://en.wikipedia.org/wiki/Backtracking
/// https://en.wikipedia.org/wiki/Nurse_scheduling_problem
///
/// DFS where maximum depth is calculated by total_days_of_month + odd_days_of_month (open shifts)
/// DFS where maximum depth is calculated by total_days_of_month + odd_days_of_month each node is called a slot
/// Starts with schedule partially completed from the user interface
/// Ends with a full schedule following restrictions and fairness
pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &Configurations) -> bool {
// check if schedule was fully filled in the previous iteration
if slot.out_of_range(config.total_days()) {
log::info!("Solution found, exiting recursive algorithm");
return true;
}
// TODO:check if slot is already assigned with manual shifts
// check if any of the rules is violated before continuing
if schedule.restrictions_violated(&slot) {
// log::info!("Cutting branch due to restriction violation");
pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserConfig) -> bool {
if !slot.is_first() && schedule.restrictions_violated(&slot.previous(), config) {
log::trace!("Cutting branch due to restriction violation");
return false;
}
// get all candidate options for current step/slot/depth
let candidates = config.candidates(slot, schedule);
if slot.greater_than(config.total_days()) {
log::trace!("Solution found, exiting recursive algorithm");
return true;
}
// TODO: sort the candidates by workload left in the month helping the algorithm
if schedule.is_slot_manually_assigned(&slot) {
return backtracking(schedule, slot.next(), config);
}
for (resident, shift_type) in candidates {
schedule.insert(slot, resident, shift_type);
// sort candidates by current workload, add rng for tie breakers
let mut candidates = config.candidates(slot, schedule);
candidates.sort_unstable_by_key(|&(resident, _)| {
let workload = schedule.current_workload(resident);
let tie_breaker: f64 = rand::rng().random();
(workload, (tie_breaker * 1000.0) as usize)
});
if backtracking(schedule, slot.increment(), config) {
log::info!("Solution found...");
for (resident, _) in candidates {
schedule.insert(slot, resident);
if backtracking(schedule, slot.next(), config) {
log::trace!("Solution found, exiting recursive algorithm");
return true;
}
schedule.remove(slot, resident, shift_type);
schedule.remove(slot);
}
false
@@ -46,21 +44,59 @@ pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &Configu
#[cfg(test)]
mod tests {
use rstest::rstest;
use rstest::{fixture, rstest};
use crate::{
config::UserConfig,
generator::backtracking,
model::{Configurations, MonthlySchedule, Slot},
resident::Resident,
schedule::MonthlySchedule,
slot::{Day, ShiftPosition, Slot},
};
#[fixture]
fn schedule() -> MonthlySchedule {
MonthlySchedule::new()
}
#[fixture]
fn config() -> UserConfig {
let mut config = 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"),
]);
config.calculate_workload_limits();
config.calculate_holiday_limits();
config
}
#[rstest]
fn test_backtracking() {
const JAN: usize = 0;
let config = Configurations::new(JAN);
let mut schedule = MonthlySchedule::new();
fn test_backtracking(mut schedule: MonthlySchedule, config: UserConfig) {
assert!(backtracking(&mut schedule, Slot::default(), &config));
backtracking(&mut schedule, Slot::default(), &config);
for d in 1..=config.total_days() {
let day = Day(d);
if day.is_open_shift() {
let slot_first = Slot::new(day, ShiftPosition::First);
assert!(schedule.get_resident_id(&slot_first).is_some());
let slot_second = Slot::new(day, ShiftPosition::Second);
assert!(schedule.get_resident_id(&slot_second).is_some());
} else {
let slot_first = Slot::new(day, ShiftPosition::First);
assert!(schedule.get_resident_id(&slot_first).is_some());
}
}
// assert!(schedule.is_filled(config.total_days()))
for r in &config.residents {
let workload = schedule.current_workload(r);
let limit = *config.workload_limits.get(&r.id).unwrap();
assert!(workload <= limit as usize);
}
println!("{}", schedule.pretty_print(&config));
}
}

View File

@@ -1,83 +1,49 @@
use log::info;
use crate::{
config::UserConfig,
export::{Export, FileType},
generator::backtracking,
model::{Configurations, Day, MonthlySchedule, Resident, Slot},
schedule::MonthlySchedule,
slot::Slot,
};
mod export;
mod fairness;
mod generator;
mod model;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
fn set_restrictions() -> String {
"A new rota begins".to_string()
}
#[tauri::command]
fn add_resident(resident: Resident) -> String {
log::info!("hi resident {:?}", resident);
format!("{:?}", resident)
}
mod config;
mod resident;
mod schedule;
mod slot;
/// argument to this must be the rota state including all
/// information for the residents, the forbidden pairs the holidays
/// and the period of the schedule
#[tauri::command]
fn generate() -> String {
const JAN: usize = 0;
const DAYS_IN_JAN: usize = 31;
let config = Configurations::new(JAN);
let config = UserConfig::default();
let mut schedule = MonthlySchedule::new();
schedule.prefill(&config);
// find total number of slots MEANING
//TODO: return a result instead of ()
backtracking(&mut schedule, Slot::default(), &config);
info!("{}", schedule.pretty_print(&config));
info!(
"Generated rota for month {}, final schedule {:?}",
JAN, schedule
);
//TODO: this should in fact return a JSON of the schedule instance solution
format!(
"Generated rota for month {}, final schedule {:?}",
JAN, schedule
)
}
// takes the list of active residents and their configurations
// providing svelte with all possible info for swapping between residents shifts
// returns the updated resident data
#[tauri::command]
fn possible_swap_locations(people: Vec<Resident>) -> Vec<Resident> {
log::info!("Fetch possible swap locations for people: {:?}", people);
people
schedule.export_as_json()
}
/// export into docx
#[tauri::command]
fn export() -> String {
// param must have filetype as string from svelte
// somehow get the current schedule
let rota = MonthlySchedule::new();
let _ = rota.export(FileType::Json);
let schedule = MonthlySchedule::new();
let filetype = FileType::Doc;
schedule.export(filetype);
// locally store the _?
todo!()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
log::info!("hi");
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
@@ -85,15 +51,7 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_log::Builder::new().build())
.invoke_handler(tauri::generate_handler![
greet,
set_restrictions,
add_resident,
generate,
possible_swap_locations,
export
])
.invoke_handler(tauri::generate_handler![generate, export])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@@ -103,5 +61,5 @@ mod tests {
use rstest::rstest;
#[rstest]
pub fn xxx() {}
pub fn test_endpoints() {}
}

View File

@@ -1,350 +0,0 @@
use std::collections::HashMap;
use chrono::Month;
use itertools::Itertools;
use log::warn;
use serde::{Deserialize, Serialize};
// here add all info that we will need when we unmake the move after recursing...
pub struct CalendarState {
// here make unmake stuff
}
pub struct Restriction {}
pub struct Rule {}
const YEAR: i32 = 2026;
pub struct Configurations {
month: Month,
holidays: Vec<usize>,
residents: Vec<Resident>,
}
impl Configurations {
pub fn new(month: usize) -> Self {
Self {
month: Month::try_from(month as u8).unwrap(),
holidays: vec![],
residents: vec![],
}
}
pub fn default() -> Self {
Self {
month: Month::try_from(0).unwrap(),
holidays: vec![],
residents: vec![],
}
}
pub fn add(&mut self, resident: Resident) {
self.residents.push(resident);
}
pub fn total_days(&self) -> u8 {
self.month.num_days(YEAR).unwrap()
}
pub fn total_open_shifts(&self) -> u8 {
let mut total_open_shifts = 0;
for i in 1..=self.total_days() {
if i % 2 != 0 {
total_open_shifts += 1;
}
}
total_open_shifts
}
pub fn total_closed_shifts(&self) -> u8 {
self.total_days() - self.total_open_shifts()
}
/// Return all possible candidates for the next slot
pub fn candidates(
&self,
slot: Slot,
schedule: &MonthlySchedule,
) -> Vec<(&Resident, &ShiftType)> {
let mut candidates = vec![];
let is_open = slot.is_open_shift();
for resident in &self.residents {
if resident.negative_shifts.contains(&slot.day) {
continue;
}
if schedule.is_already_assigned_in_slot(&slot) {
continue;
}
for shift_type in &resident.allowed_types {
match shift_type {
ShiftType::OpenFirst => {
if is_open {
candidates.push((resident, &ShiftType::OpenFirst))
}
}
ShiftType::OpenSecond => {
if is_open {
candidates.push((resident, &ShiftType::OpenSecond))
}
}
ShiftType::Closed => {
if !is_open {
candidates.push((resident, &ShiftType::Closed))
}
}
}
}
}
candidates
}
}
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct Slot {
day: Day,
index: usize,
}
impl Slot {
pub fn new(day: Day, index: usize) -> Self {
Self { day, index }
}
pub fn is_open_shift(&self) -> bool {
self.day.is_open_shift()
}
pub fn increment(&self) -> Self {
if self.index == 0 {
Self {
day: self.day,
index: 1,
}
} else {
Self {
day: self.day.increment(),
index: 0,
}
}
}
pub fn out_of_range(&self, limit: u8) -> bool {
self.day.0 > limit
}
pub fn default() -> Self {
Self {
day: Day(0),
index: 0,
}
}
}
#[derive(Debug)]
pub struct MonthlySchedule(HashMap<Slot, Shift>);
impl MonthlySchedule {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn get_resident(&self, slot: &Slot) -> Option<String> {
let shift = self.0.get(slot);
if let Some(shift) = shift {
if slot.index == 0 {
return shift.resident_1_id.clone();
} else {
return shift.resident_1_id.clone();
}
}
None
}
pub fn insert(&mut self, slot: Slot, resident: &Resident, shift_type: &ShiftType) {
match self.0.get_mut(&slot) {
Some(shift) => {
warn!("one shift already set for today");
if shift.resident_2_id.is_none() && slot.is_open_shift() {
shift.resident_2_id = Some(resident.id.clone())
}
}
None => {
let new_shift = Shift::new(resident.id.clone());
self.0.insert(slot, new_shift);
}
}
}
pub fn remove(&mut self, slot: Slot, resident: &Resident, shift_type: &ShiftType) {
if let Some(shift) = self.0.get_mut(&slot) {
if let Some(res_1_id) = &shift.resident_1_id {
if &resident.id == res_1_id {
shift.resident_1_id = None;
}
} else if let Some(res_1_id) = &shift.resident_2_id {
if &resident.id == res_1_id {
shift.resident_1_id = None;
}
}
}
}
pub fn is_already_assigned_in_slot(&self, slot: &Slot) -> bool {
if let Some(shift) = self.0.get(slot) {
if shift.resident_1_id.is_some() || shift.resident_2_id.is_some() {
return true;
}
}
false
}
/// here we check for the validity of all environment restrictions
/// if any is violated we return true (leading to pruning in the backtracking algorithm)
pub fn restrictions_violated(&self, slot: &Slot) -> bool {
// self.contains_any_consecutive_shifts()
// see if the spot in the self calendar for the slot has the same person like the previous?
// this would take out both back to back days, but also same day 2 times the same resident
let resident = self.get_resident(slot);
todo!()
}
/// return true if
pub fn contains_any_consecutive_shifts(&self) -> bool {
// self.0
// .into_iter()
// .sorted_unstable_by_key(|shift| shift.day)
// .tuple_windows()
// .any(|((_, (p1, p1_opt)), (_, (p2, p2_opt)))| {
// let p1_name = &p1.name;
// let p1_opt_name = p1_opt
// .as_ref()
// .unwrap_or(&Resident::new(".", "-p1"))
// .name
// .clone();
// let p2_name = &p2.name;
// let p2_opt_name = p2_opt
// .as_ref()
// .unwrap_or(&Resident::new(".", "-p2"))
// .name
// .clone();
// p1_name == p2_name
// || p1_name == &p2_opt_name
// || p2_name == &p1_opt_name
// || p1_opt_name == p2_opt_name
// });
todo!()
}
}
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct Day(pub u8);
impl Day {
pub fn is_open_shift(&self) -> bool {
!self.0.is_multiple_of(2)
}
pub fn increment(&self) -> Self {
Self(self.0 + 1)
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Resident {
id: String,
name: String,
negative_shifts: Vec<Day>,
// manual days on
manual_shifts: Vec<Day>,
max_shifts: Option<usize>,
allowed_types: Vec<ShiftType>,
reduced_load: bool,
}
impl Resident {
pub fn new(id: &str, name: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
negative_shifts: Vec::new(),
manual_shifts: Vec::new(),
max_shifts: None,
allowed_types: vec![
ShiftType::OpenFirst,
ShiftType::OpenSecond,
ShiftType::Closed,
],
reduced_load: false,
}
}
}
pub struct WeeklySchedule {
// todo
}
impl WeeklySchedule {
// todo
}
// this is agnostic to what specific types the resident shave
// we cant add here shift_Type except if we just do OPEN/CLOSED.
// also there's a discussion to be had regardng the Option of resident 1 id since a shift should always have one..
// (But due to the backtracking algorithm maybe its ok to work without any residents in a shift? cauise we add/remove all the time)
#[derive(Debug)]
pub struct Shift {
resident_1_id: Option<String>,
// should be filled in odd days, in other words in open shifts (OpenFirst, OpenSecond)
resident_2_id: Option<String>,
}
impl Shift {
pub fn new_empty() -> Self {
Self {
resident_1_id: None,
resident_2_id: None,
}
}
pub fn new(resident_1_id: String) -> Self {
Self {
resident_1_id: Some(resident_1_id),
resident_2_id: None,
}
}
pub fn new_open(resident_1_id: String, resident_2_id: String) -> Self {
Self {
resident_1_id: Some(resident_1_id),
resident_2_id: Some(resident_2_id),
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub enum ShiftType {
Closed,
OpenFirst,
OpenSecond,
}
#[cfg(test)]
mod tests {
use rstest::rstest;
#[rstest]
pub fn xxx() {}
}

46
src-tauri/src/resident.rs Normal file
View File

@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use crate::{
schedule::ShiftType,
slot::{Day, Slot},
};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Resident {
pub id: String,
pub name: String,
pub negative_shifts: Vec<Day>,
pub manual_shifts: Vec<Slot>,
pub max_shifts: Option<usize>,
pub allowed_types: Vec<ShiftType>,
pub reduced_load: bool,
}
impl Resident {
pub fn new(id: &str, name: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
negative_shifts: Vec::new(),
manual_shifts: Vec::new(),
max_shifts: None,
allowed_types: vec![
ShiftType::OpenFirst,
ShiftType::OpenSecond,
ShiftType::Closed,
],
reduced_load: false,
}
}
pub fn with_max_shifts(mut self, max_shifts: usize) -> Self {
self.max_shifts = Some(max_shifts);
self
}
pub fn with_reduced_load(mut self) -> Self {
self.reduced_load = true;
self
}
}

324
src-tauri/src/schedule.rs Normal file
View File

@@ -0,0 +1,324 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::{
config::UserConfig,
resident::Resident,
slot::{Day, Slot},
};
// each slot has one resident
// a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even)
#[derive(Debug)]
pub struct MonthlySchedule(HashMap<Slot, String>);
impl MonthlySchedule {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn prefill(&mut self, config: &UserConfig) {
for r in &config.residents {
for s in &r.manual_shifts {
self.insert(*s, r);
}
}
}
pub fn get_resident_id(&self, slot: &Slot) -> Option<&String> {
self.0.get(slot)
}
pub fn current_workload(&self, resident: &Resident) -> usize {
self.0
.values()
.filter(|res_id| res_id == &&resident.id)
.count()
}
pub fn current_holiday_workload(&self, resident: &Resident, config: &UserConfig) -> usize {
self.0
.iter()
.filter(|(slot, res_id)| {
res_id == &&resident.id && config.is_holiday_or_weekend_slot(slot.day.0)
})
.count()
}
pub fn insert(&mut self, slot: Slot, resident: &Resident) {
self.0.insert(slot, resident.id.clone());
}
pub fn remove(&mut self, slot: Slot) {
self.0.remove(&slot);
}
pub fn is_slot_manually_assigned(&self, slot: &Slot) -> bool {
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) -> bool {
self.same_resident_in_consecutive_days(slot)
|| self.has_toxic_pair(slot, config)
|| self.is_workload_unbalanced(slot, config)
|| self.is_holiday_workload_imbalanced(slot, config)
}
/// same_resident_in_consecutive_days
pub fn same_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
if slot.day == Day(1) {
return false;
}
let previous_slot = if slot.is_open_second() {
slot.previous().previous()
} else {
slot.previous()
};
self.get_resident_id(&previous_slot) == self.get_resident_id(slot)
}
/// has_toxic_pair
pub fn has_toxic_pair(&self, slot: &Slot, config: &UserConfig) -> bool {
// if it is not an open shift and we only just added the first person in an open shift
// then we couldn't have just created a new toxic pair
if !slot.is_open_shift() || !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(f), Some(s)) = (first_id, second_id) {
return config
.toxic_pairs
.iter()
.any(|(r1, r2)| (r1 == f && r2 == s) || (r1 == s && r2 == f));
}
false
}
/// is_workload_unbalanced
pub fn is_workload_unbalanced(&self, slot: &Slot, config: &UserConfig) -> bool {
let res_id = match self.get_resident_id(slot) {
Some(id) => id,
None => return false,
};
if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) {
let current_workload = self.current_workload(resident);
if let Some(&limit) = config.workload_limits.get(res_id) {
let mut workload_limit = limit;
if resident.reduced_load {
workload_limit -= 1;
}
if current_workload > workload_limit as usize {
return true;
}
}
}
false
}
/// is_holiday_workload_imbalanced
pub fn is_holiday_workload_imbalanced(&self, slot: &Slot, config: &UserConfig) -> bool {
if !config.is_holiday_or_weekend_slot(slot.day.0) {
return false;
}
let res_id = match self.get_resident_id(slot) {
Some(id) => id,
None => return false,
};
if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) {
let current_holiday_workload = self.current_holiday_workload(resident, config);
if let Some(&holiday_limit) = config.holiday_limits.get(res_id) {
if current_holiday_workload > holiday_limit as usize {
return true;
}
}
}
false
}
pub fn pretty_print(&self, config: &UserConfig) -> String {
let mut sorted: Vec<_> = self.0.iter().collect();
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
let mut output = String::from("MonthlySchedule {\n");
for (slot, res_id) in sorted {
let res_name = config
.residents
.iter()
.find(|r| &r.id == res_id)
.map(|r| r.name.as_str())
.unwrap();
output.push_str(&format!(
"Day {:2} ({:?}): {},\n",
slot.day.0, slot.position, res_name
));
}
output.push('}');
output
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub enum ShiftType {
Closed,
OpenFirst,
OpenSecond,
}
#[cfg(test)]
mod tests {
use rstest::{fixture, rstest};
use crate::{
config::UserConfig,
resident::Resident,
schedule::{Day, MonthlySchedule, Slot},
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![(("1".to_string(), "2".to_string()))])
}
#[fixture]
fn config() -> UserConfig {
UserConfig::default().with_residents(vec![
Resident::new("1", "Stefanos"),
Resident::new("2", "Iordanis"),
])
}
#[rstest]
fn test_insert_resident(mut schedule: MonthlySchedule, resident: Resident) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
schedule.insert(slot_1, &resident);
assert_eq!(schedule.get_resident_id(&slot_1), Some(&"1".to_string()));
assert_eq!(schedule.current_workload(&resident), 1);
assert_eq!(schedule.get_resident_id(&slot_2), None);
}
#[rstest]
fn test_remove_resident(mut schedule: MonthlySchedule, resident: Resident) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
schedule.insert(slot_1, &resident);
assert_eq!(schedule.current_workload(&resident), 1);
schedule.remove(slot_1);
assert_eq!(schedule.get_resident_id(&slot_1), None);
assert_eq!(schedule.current_workload(&resident), 0);
}
#[rstest]
fn test_same_resident_in_consecutive_days(mut schedule: MonthlySchedule, resident: Resident) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_3 = Slot::new(Day(2), ShiftPosition::First);
schedule.insert(slot_1, &resident);
schedule.insert(slot_2, &resident);
schedule.insert(slot_3, &resident);
assert!(!schedule.same_resident_in_consecutive_days(&slot_1));
assert!(!schedule.same_resident_in_consecutive_days(&slot_2));
assert!(schedule.same_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);
schedule.insert(slot_2, iordanis);
assert!(schedule.has_toxic_pair(&slot_2, &toxic_config))
}
#[rstest]
fn test_is_workload_unbalanced(mut schedule: MonthlySchedule, mut config: UserConfig) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_3 = Slot::new(Day(2), ShiftPosition::First);
let stefanos = &config.residents[0];
let iordanis = &config.residents[1];
config.workload_limits.insert("1".to_string(), 1);
config.workload_limits.insert("2".to_string(), 2);
schedule.insert(slot_1, &stefanos);
assert!(!schedule.is_workload_unbalanced(&slot_1, &config));
schedule.insert(slot_2, &iordanis);
assert!(!schedule.is_workload_unbalanced(&slot_2, &config));
schedule.insert(slot_3, &stefanos);
assert!(schedule.is_workload_unbalanced(&slot_3, &config));
}
#[rstest]
fn test_is_holiday_workload_imbalanced(mut schedule: MonthlySchedule, mut config: UserConfig) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_7 = Slot::new(Day(7), ShiftPosition::First);
let stefanos = &config.residents[0];
let iordanis = &config.residents[1];
config.holiday_limits.insert("1".to_string(), 1);
config.holiday_limits.insert("2".to_string(), 1);
schedule.insert(slot_1, &stefanos);
assert!(!schedule.is_holiday_workload_imbalanced(&slot_1, &config));
schedule.insert(slot_2, &iordanis);
assert!(!schedule.is_holiday_workload_imbalanced(&slot_2, &config));
schedule.insert(slot_7, &stefanos);
assert!(schedule.is_holiday_workload_imbalanced(&slot_7, &config));
}
}

173
src-tauri/src/slot.rs Normal file
View File

@@ -0,0 +1,173 @@
use chrono::{Datelike, NaiveDate, Weekday};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct Slot {
pub day: Day,
pub position: ShiftPosition,
}
impl Slot {
pub fn new(day: Day, position: ShiftPosition) -> Self {
Self { day, position }
}
pub fn is_open_shift(&self) -> bool {
self.day.is_open_shift()
}
pub fn is_first(&self) -> bool {
self.day == Day(1) && self.position == ShiftPosition::First
}
pub fn is_open_second(&self) -> bool {
self.is_open_shift() && self.position == ShiftPosition::Second
}
pub fn next(&self) -> Self {
match self.position {
ShiftPosition::First if self.is_open_shift() => Self {
day: self.day,
position: ShiftPosition::Second,
},
_ => Self {
day: self.day.next(),
position: ShiftPosition::First,
},
}
}
pub fn previous(&self) -> Self {
match self.position {
ShiftPosition::First => {
let past_day = self.day.previous();
if past_day.is_open_shift() {
Self {
day: past_day,
position: ShiftPosition::Second,
}
} else {
Self {
day: past_day,
position: ShiftPosition::First,
}
}
}
ShiftPosition::Second => Self {
day: self.day,
position: ShiftPosition::First,
},
}
}
pub fn greater_than(&self, limit: u8) -> bool {
self.day.greater_than(&Day(limit))
}
pub fn default() -> Self {
Self {
day: Day(1),
position: ShiftPosition::First,
}
}
}
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub struct Day(pub u8);
impl Day {
pub fn is_open_shift(&self) -> bool {
!self.0.is_multiple_of(2)
}
pub fn next(&self) -> Self {
Self(self.0 + 1)
}
pub fn previous(&self) -> Self {
if self.0 <= 1 {
Self(1)
} else {
Self(self.0 - 1)
}
}
pub fn greater_than(&self, other: &Day) -> bool {
self.0 > other.0
}
pub fn is_weekend(&self, month: u32, year: i32) -> bool {
let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32).unwrap();
let weekday = date.weekday();
weekday == Weekday::Sat || weekday == Weekday::Sun
}
}
#[derive(Serialize, Deserialize, PartialEq, PartialOrd, Ord, Eq, Debug, Hash, Clone, Copy)]
pub enum ShiftPosition {
First,
Second,
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use crate::slot::{Day, ShiftPosition, Slot};
#[rstest]
fn test_slot() {
let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_3 = Slot::new(Day(2), ShiftPosition::First);
assert!(slot_1.is_open_shift());
assert!(slot_2.is_open_shift());
assert!(!slot_3.is_open_shift());
assert!(slot_1.is_first());
assert!(!slot_2.is_first());
assert!(!slot_3.is_first());
assert!(!slot_1.is_open_second());
assert!(slot_2.is_open_second());
assert!(!slot_3.is_open_second());
assert_eq!(slot_1.next(), slot_2);
assert_eq!(slot_2.next(), slot_3);
assert_eq!(slot_3.previous(), slot_2);
assert_eq!(slot_2.previous(), slot_1);
assert!(!slot_1.greater_than(1));
assert!(!slot_2.greater_than(1));
assert!(slot_3.greater_than(1));
}
#[rstest]
fn test_day() {
let day_1 = Day(1);
let day_2 = Day(2);
let day_3 = Day(3);
assert!(day_1.is_open_shift());
assert!(!day_2.is_open_shift());
assert!(day_3.is_open_shift());
assert_eq!(day_1.next(), day_2);
assert_eq!(day_2.next(), day_3);
assert_eq!(day_3.previous(), day_2);
assert_eq!(day_2.previous(), day_1);
assert!(!day_1.greater_than(&day_1));
assert!(day_2.greater_than(&day_1));
assert!(day_3.greater_than(&day_1));
assert!(day_1.is_weekend(2, 2026));
assert!(!day_2.is_weekend(2, 2026));
assert!(!day_3.is_weekend(2, 2026));
}
}