diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 64b6c5c..4d18acf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 99b831a..5276c7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,3 +27,4 @@ itertools = "0.14.0" rstest = "0.26.1" tauri-plugin-log = "2" log = "0.4.29" +rand = "0.9.2" diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..0aab83d --- /dev/null +++ b/src-tauri/src/config.rs @@ -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, + pub residents: Vec, + pub toxic_pairs: Vec<(String, String)>, + + // calculated from inputs + pub workload_limits: HashMap, + pub holiday_limits: HashMap, +} + +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) -> Self { + self.holidays = holidays; + self + } + + pub fn with_residents(mut self, residents: Vec) -> 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()); + } +} diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 2e39cdd..d857638 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -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; diff --git a/src-tauri/src/fairness.rs b/src-tauri/src/fairness.rs deleted file mode 100644 index 7868592..0000000 --- a/src-tauri/src/fairness.rs +++ /dev/null @@ -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!() -} diff --git a/src-tauri/src/generator.rs b/src-tauri/src/generator.rs index f200c52..77d286b 100644 --- a/src-tauri/src/generator.rs +++ b/src-tauri/src/generator.rs @@ -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)); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2ea5590..195ccbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) -> Vec { - 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() {} } diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs deleted file mode 100644 index 6e6e601..0000000 --- a/src-tauri/src/model.rs +++ /dev/null @@ -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, - residents: Vec, -} - -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); - -impl MonthlySchedule { - pub fn new() -> Self { - Self(HashMap::new()) - } - - pub fn get_resident(&self, slot: &Slot) -> Option { - 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, - // manual days on - manual_shifts: Vec, - max_shifts: Option, - allowed_types: Vec, - 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, - // should be filled in odd days, in other words in open shifts (OpenFirst, OpenSecond) - resident_2_id: Option, -} - -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() {} -} diff --git a/src-tauri/src/resident.rs b/src-tauri/src/resident.rs new file mode 100644 index 0000000..89de7ab --- /dev/null +++ b/src-tauri/src/resident.rs @@ -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, + pub manual_shifts: Vec, + pub max_shifts: Option, + pub allowed_types: Vec, + 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 + } +} diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs new file mode 100644 index 0000000..095623a --- /dev/null +++ b/src-tauri/src/schedule.rs @@ -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); + +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)); + } +} diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs new file mode 100644 index 0000000..db463bf --- /dev/null +++ b/src-tauri/src/slot.rs @@ -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)); + } +}