Change ResidentId(String) to ResidentId(u8), impl Copy for ShiftType, use u8 for all UserConfig params

This commit is contained in:
2026-01-17 19:27:35 +02:00
parent 5bad63e8a7
commit 125ddc3117
8 changed files with 161 additions and 192 deletions

View File

@@ -12,11 +12,8 @@ const YEAR: i32 = 2026;
pub struct ToxicPair((ResidentId, ResidentId)); pub struct ToxicPair((ResidentId, ResidentId));
impl ToxicPair { impl ToxicPair {
pub fn new(res_id_1: &str, res_id_2: &str) -> Self { pub fn new(res_id_1: u8, res_id_2: u8) -> Self {
Self(( Self((ResidentId(res_id_1), ResidentId(res_id_2)))
ResidentId(res_id_1.to_string()),
ResidentId(res_id_2.to_string()),
))
} }
pub fn matches(&self, other: &ToxicPair) -> bool { pub fn matches(&self, other: &ToxicPair) -> bool {
@@ -35,18 +32,18 @@ impl From<(ResidentId, ResidentId)> for ToxicPair {
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserConfigDTO { pub struct UserConfigDTO {
month: usize, month: u8,
year: i32, year: i32,
holidays: Vec<usize>, holidays: Vec<u8>,
residents: Vec<ResidentDTO>, residents: Vec<ResidentDTO>,
toxic_pairs: Vec<(String, String)>, toxic_pairs: Vec<(u8, u8)>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserConfig { pub struct UserConfig {
pub month: Month, pub month: Month,
pub year: i32, pub year: i32,
pub holidays: Vec<usize>, pub holidays: Vec<u8>,
pub residents: Vec<Resident>, pub residents: Vec<Resident>,
pub toxic_pairs: Vec<ToxicPair>, pub toxic_pairs: Vec<ToxicPair>,
@@ -80,7 +77,7 @@ impl UserConfig {
} }
} }
pub fn with_holidays(mut self, holidays: Vec<usize>) -> 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 = self.total_holiday_slots();
self self
@@ -110,7 +107,7 @@ impl UserConfig {
pub fn is_holiday_or_weekend_slot(&self, day: u8) -> bool { pub fn is_holiday_or_weekend_slot(&self, day: u8) -> bool {
let day = Day(day); let day = Day(day);
day.is_weekend(self.month.number_from_month(), self.year) day.is_weekend(self.month.number_from_month(), self.year)
|| self.holidays.contains(&(day.0 as usize)) || self.holidays.contains(&(day.0))
} }
} }
@@ -144,7 +141,7 @@ impl Default for UserConfig {
impl From<UserConfigDTO> for UserConfig { impl From<UserConfigDTO> for UserConfig {
fn from(value: UserConfigDTO) -> Self { fn from(value: UserConfigDTO) -> Self {
let month = Month::try_from(value.month as u8).unwrap(); let month = Month::try_from(value.month).unwrap();
let total_days = month.num_days(YEAR).unwrap(); let total_days = month.num_days(YEAR).unwrap();
@@ -155,7 +152,7 @@ impl From<UserConfigDTO> for UserConfig {
let total_holiday_slots = (1..=total_days) let total_holiday_slots = (1..=total_days)
.filter(|&d| { .filter(|&d| {
Day(d).is_weekend(month.number_from_month(), value.year) Day(d).is_weekend(month.number_from_month(), value.year)
|| value.holidays.contains(&(d as usize)) || value.holidays.contains(&d)
}) })
.map(|d| if Day(d).is_open_shift() { 2 } else { 1 }) .map(|d| if Day(d).is_open_shift() { 2 } else { 1 })
.sum(); .sum();
@@ -168,7 +165,7 @@ impl From<UserConfigDTO> for UserConfig {
toxic_pairs: value toxic_pairs: value
.toxic_pairs .toxic_pairs
.into_iter() .into_iter()
.map(|p| ToxicPair::new(&p.0, &p.1)) .map(|p| ToxicPair::new(p.0, p.1))
.collect(), .collect(),
total_days, total_days,
total_slots, total_slots,

View File

@@ -179,12 +179,12 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Στέφανος"), Resident::new(1, "Στέφανος"),
Resident::new("2", "Ιορδάνης"), Resident::new(2, "Ιορδάνης"),
Resident::new("3", "Μαρία"), Resident::new(3, "Μαρία"),
Resident::new("4", "Βεατρίκη"), Resident::new(4, "Βεατρίκη"),
Resident::new("5", "Τάκης"), Resident::new(5, "Τάκης"),
Resident::new("6", "Μάκης"), Resident::new(6, "Μάκης"),
]) ])
} }

View File

@@ -5,8 +5,8 @@ use crate::{
slot::{Day, ShiftPosition, Slot}, slot::{Day, ShiftPosition, Slot},
}; };
#[derive(Serialize, Deserialize, Debug, Clone, Hash, Eq, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, Hash, Eq, PartialEq, Copy)]
pub struct ResidentId(pub String); pub struct ResidentId(pub u8);
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -15,7 +15,7 @@ pub struct Resident {
pub name: String, pub name: String,
pub negative_shifts: Vec<Day>, pub negative_shifts: Vec<Day>,
pub manual_shifts: Vec<Slot>, pub manual_shifts: Vec<Slot>,
pub max_shifts: Option<usize>, pub max_shifts: Option<u8>,
pub allowed_types: Vec<ShiftType>, pub allowed_types: Vec<ShiftType>,
pub reduced_load: bool, pub reduced_load: bool,
} }
@@ -23,19 +23,19 @@ pub struct Resident {
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ResidentDTO { pub struct ResidentDTO {
id: String, id: u8,
name: String, name: String,
negative_shifts: Vec<usize>, negative_shifts: Vec<u8>,
manual_shifts: Vec<Slot>, manual_shifts: Vec<Slot>,
max_shifts: Option<usize>, max_shifts: Option<u8>,
allowed_types: Vec<ShiftType>, allowed_types: Vec<ShiftType>,
reduced_load: bool, reduced_load: bool,
} }
impl Resident { impl Resident {
pub fn new(id: &str, name: &str) -> Self { pub fn new(id: u8, name: &str) -> Self {
Self { Self {
id: ResidentId(id.to_string()), id: ResidentId(id),
name: name.to_string(), name: name.to_string(),
negative_shifts: Vec::new(), negative_shifts: Vec::new(),
manual_shifts: Vec::new(), manual_shifts: Vec::new(),
@@ -54,7 +54,7 @@ impl Resident {
self self
} }
pub fn with_max_shifts(mut self, max_shifts: usize) -> Self { pub fn with_max_shifts(mut self, max_shifts: u8) -> Self {
self.max_shifts = Some(max_shifts); self.max_shifts = Some(max_shifts);
self self
} }
@@ -80,11 +80,7 @@ impl From<ResidentDTO> for Resident {
Self { Self {
id: ResidentId(value.id), id: ResidentId(value.id),
name: value.name, name: value.name,
negative_shifts: value negative_shifts: value.negative_shifts.into_iter().map(Day).collect(),
.negative_shifts
.into_iter()
.map(|d| Day(d as u8))
.collect(),
manual_shifts: value manual_shifts: value
.manual_shifts .manual_shifts
.into_iter() .into_iter()

View File

@@ -23,7 +23,7 @@ impl MonthlySchedule {
pub fn prefill(&mut self, config: &UserConfig) { pub fn prefill(&mut self, config: &UserConfig) {
for r in &config.residents { for r in &config.residents {
for s in &r.manual_shifts { for s in &r.manual_shifts {
self.insert(*s, &r.id); self.insert(*s, r.id);
} }
} }
} }
@@ -32,8 +32,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, resident_id: ResidentId) {
self.0.insert(slot, resident_id.clone()); self.0.insert(slot, resident_id);
} }
pub fn remove(&mut self, slot: Slot) { pub fn remove(&mut self, slot: Slot) {
@@ -102,7 +102,7 @@ impl MonthlySchedule {
return config return config
.toxic_pairs .toxic_pairs
.iter() .iter()
.any(|pair| pair.matches(&ToxicPair::from((r1.clone(), r2.clone())))); .any(|pair| pair.matches(&ToxicPair::from((*r1, *r2))));
} }
false false
@@ -184,7 +184,7 @@ impl Serialize for MonthlySchedule {
} }
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Copy)]
pub enum ShiftType { pub enum ShiftType {
Closed, Closed,
OpenFirst, OpenFirst,
@@ -209,24 +209,24 @@ mod tests {
#[fixture] #[fixture]
fn resident() -> Resident { fn resident() -> Resident {
Resident::new("1", "Stefanos") Resident::new(1, "Stefanos")
} }
#[fixture] #[fixture]
fn toxic_config() -> UserConfig { fn toxic_config() -> UserConfig {
UserConfig::default() UserConfig::default()
.with_residents(vec![ .with_residents(vec![
Resident::new("1", "Stefanos"), Resident::new(1, "Stefanos"),
Resident::new("2", "Iordanis"), Resident::new(2, "Iordanis"),
]) ])
.with_toxic_pairs(vec![ToxicPair::new("1", "2")]) .with_toxic_pairs(vec![ToxicPair::new(1, 2)])
} }
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Stefanos"), Resident::new(1, "Stefanos"),
Resident::new("2", "Iordanis"), Resident::new(2, "Iordanis"),
]) ])
} }
@@ -235,12 +235,9 @@ mod tests {
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);
schedule.insert(slot_1, &resident.id); schedule.insert(slot_1, resident.id);
assert_eq!( assert_eq!(schedule.get_resident_id(&slot_1), Some(&ResidentId(1)));
schedule.get_resident_id(&slot_1),
Some(&ResidentId("1".to_string()))
);
assert_eq!(schedule.get_resident_id(&slot_2), None); assert_eq!(schedule.get_resident_id(&slot_2), None);
} }
@@ -248,7 +245,7 @@ mod tests {
fn test_remove_resident(mut schedule: MonthlySchedule, resident: Resident) { fn test_remove_resident(mut schedule: MonthlySchedule, resident: Resident) {
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);
schedule.remove(slot_1); schedule.remove(slot_1);
assert_eq!(schedule.get_resident_id(&slot_1), None); assert_eq!(schedule.get_resident_id(&slot_1), None);
@@ -260,9 +257,9 @@ mod tests {
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);
schedule.insert(slot_1, &resident.id); schedule.insert(slot_1, resident.id);
schedule.insert(slot_2, &resident.id); schedule.insert(slot_2, resident.id);
schedule.insert(slot_3, &resident.id); schedule.insert(slot_3, resident.id);
assert!(!schedule.has_resident_in_consecutive_days(&slot_1)); assert!(!schedule.has_resident_in_consecutive_days(&slot_1));
assert!(!schedule.has_resident_in_consecutive_days(&slot_2)); assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
@@ -277,8 +274,8 @@ mod tests {
let stefanos = &toxic_config.residents[0]; let stefanos = &toxic_config.residents[0];
let iordanis = &toxic_config.residents[1]; let iordanis = &toxic_config.residents[1];
schedule.insert(slot_1, &stefanos.id); schedule.insert(slot_1, stefanos.id);
schedule.insert(slot_2, &iordanis.id); schedule.insert(slot_2, iordanis.id);
assert!(schedule.has_toxic_pair(&slot_2, &toxic_config)) assert!(schedule.has_toxic_pair(&slot_2, &toxic_config))
} }

View File

@@ -27,7 +27,7 @@ impl Scheduler {
pub fn run(&self, schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker) -> bool { pub fn run(&self, schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker) -> bool {
schedule.prefill(&self.config); schedule.prefill(&self.config);
for (slot, res_id) in schedule.0.iter() { for (slot, res_id) in schedule.0.iter() {
tracker.insert(res_id, &self.config, *slot); tracker.insert(*res_id, &self.config, *slot);
} }
self.search(schedule, tracker, Slot::default()) self.search(schedule, tracker, Slot::default())
@@ -64,7 +64,7 @@ impl Scheduler {
(workload, (tie_breaker * 1000.0) as usize) (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);
tracker.insert(id, &self.config, slot); tracker.insert(id, &self.config, slot);
@@ -80,7 +80,7 @@ impl Scheduler {
} }
/// 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> { pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec<ResidentId> {
let required_type = slot.shift_type(); let required_type = slot.shift_type();
let other_resident = slot let other_resident = slot
.other_position() .other_position()
@@ -94,7 +94,7 @@ impl Scheduler {
&& !r.negative_shifts.contains(&slot.day) && !r.negative_shifts.contains(&slot.day)
&& r.allowed_types.contains(&required_type) && r.allowed_types.contains(&required_type)
}) })
.map(|r| &r.id) .map(|r| r.id)
.collect() .collect()
} }
} }
@@ -120,12 +120,12 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Stefanos"), Resident::new(1, "Stefanos"),
Resident::new("2", "Iordanis"), Resident::new(2, "Iordanis"),
Resident::new("3", "Maria"), Resident::new(3, "Maria"),
Resident::new("4", "Veatriki"), Resident::new(4, "Veatriki"),
Resident::new("5", "Takis"), Resident::new(5, "Takis"),
Resident::new("6", "Akis"), Resident::new(6, "Akis"),
]) ])
} }

View File

@@ -52,8 +52,7 @@ impl WorkloadBounds {
// if all residents have a manually set max shifts size, just use those values for the max workload // if all residents have a manually set max shifts size, just use those values for the max workload
if auto_computed_residents.is_empty() { if auto_computed_residents.is_empty() {
for r in &config.residents { for r in &config.residents {
self.max_workloads self.max_workloads.insert(r.id, r.max_shifts.unwrap_or(0));
.insert(r.id.clone(), r.max_shifts.unwrap_or(0) as u8);
} }
return; return;
} }
@@ -61,23 +60,23 @@ impl WorkloadBounds {
// Untested scenario: Resident has manual max_shifts and also reduced workload flag // Untested scenario: Resident has manual max_shifts and also reduced workload flag
// Probably should forbid using both options from GUI // Probably should forbid using both options from GUI
let manual_max_shifts_sum: usize = config let manual_max_shifts_sum: u8 = config
.residents .residents
.iter() .iter()
.map(|r| r.max_shifts.unwrap_or(0)) .map(|r| r.max_shifts.unwrap_or(0))
.sum(); .sum();
let max_shifts_ceiling = ((config.total_slots as usize - manual_max_shifts_sum) as f32 let max_shifts_ceiling = ((config.total_slots - manual_max_shifts_sum) as f32
/ auto_computed_residents.len() as f32) / auto_computed_residents.len() as f32)
.ceil() as u8; .ceil() as u8;
for r in &config.residents { for r in &config.residents {
let max_shifts = match r.max_shifts { let max_shifts = match r.max_shifts {
Some(shifts) => shifts as u8, Some(shifts) => shifts,
None if r.reduced_load => max_shifts_ceiling - 1, None if r.reduced_load => max_shifts_ceiling - 1,
None => max_shifts_ceiling, None => max_shifts_ceiling,
}; };
self.max_workloads.insert(r.id.clone(), max_shifts); self.max_workloads.insert(r.id, max_shifts);
} }
} }
@@ -91,7 +90,7 @@ impl WorkloadBounds {
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32; let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32;
let holiday_limit = share.ceil() as u8; let holiday_limit = share.ceil() as u8;
self.max_holiday_shifts.insert(r.id.clone(), holiday_limit); self.max_holiday_shifts.insert(r.id, holiday_limit);
} }
} }
@@ -112,23 +111,20 @@ impl WorkloadBounds {
.iter() .iter()
.filter(|r| r.allowed_types.len() == 1) .filter(|r| r.allowed_types.len() == 1)
{ {
let shift_type = &res.allowed_types[0]; let shift_type = res.allowed_types[0];
let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0); let total_limit = *self.max_workloads.get(&res.id).unwrap_or(&0);
local_limits.insert((res.id.clone(), shift_type.clone()), total_limit); local_limits.insert((res.id, shift_type), total_limit);
local_thresholds.insert( local_thresholds.insert((res.id, shift_type), total_limit.saturating_sub(2));
(res.id.clone(), shift_type.clone()),
total_limit.saturating_sub(2),
);
for other_type in &all_shift_types { for other_type in all_shift_types {
if other_type != shift_type { if other_type != shift_type {
local_limits.insert((res.id.clone(), other_type.clone()), 0); local_limits.insert((res.id, other_type), 0);
local_thresholds.insert((res.id.clone(), other_type.clone()), 0); local_thresholds.insert((res.id, other_type), 0);
} }
} }
if let Some(s) = supply_by_shift_type.get_mut(shift_type) { if let Some(s) = supply_by_shift_type.get_mut(&shift_type) {
*s = s.saturating_sub(total_limit) *s = s.saturating_sub(total_limit)
} }
} }
@@ -144,19 +140,16 @@ impl WorkloadBounds {
let deduct_amount = (total_limit as f32 / 2.0) as u8; let deduct_amount = (total_limit as f32 / 2.0) as u8;
for shift_type in &all_shift_types { for shift_type in all_shift_types {
if res.allowed_types.contains(shift_type) { if res.allowed_types.contains(&shift_type) {
local_limits.insert((res.id.clone(), shift_type.clone()), per_type); local_limits.insert((res.id, shift_type), per_type);
local_thresholds.insert( local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2));
(res.id.clone(), shift_type.clone()), if let Some(s) = supply_by_shift_type.get_mut(&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); *s = s.saturating_sub(deduct_amount);
} }
} else { } else {
local_limits.insert((res.id.clone(), shift_type.clone()), 0); local_limits.insert((res.id, shift_type), 0);
local_thresholds.insert((res.id.clone(), shift_type.clone()), 0); local_thresholds.insert((res.id, shift_type), 0);
} }
} }
} }
@@ -172,19 +165,16 @@ impl WorkloadBounds {
let deduct_amount = (total_limit as f32 / 3.0) as u8; let deduct_amount = (total_limit as f32 / 3.0) as u8;
for shift_type in &all_shift_types { for shift_type in all_shift_types {
if res.allowed_types.contains(shift_type) { if res.allowed_types.contains(&shift_type) {
local_limits.insert((res.id.clone(), shift_type.clone()), per_type); local_limits.insert((res.id, shift_type), per_type);
local_thresholds.insert( local_thresholds.insert((res.id, shift_type), per_type.saturating_sub(2));
(res.id.clone(), shift_type.clone()), if let Some(s) = supply_by_shift_type.get_mut(&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); *s = s.saturating_sub(deduct_amount);
} }
} else { } else {
local_limits.insert((res.id.clone(), shift_type.clone()), 0); local_limits.insert((res.id, shift_type), 0);
local_thresholds.insert((res.id.clone(), shift_type.clone()), 0); local_thresholds.insert((res.id, shift_type), 0);
} }
} }
} }
@@ -202,32 +192,29 @@ pub struct WorkloadTracker {
} }
impl WorkloadTracker { impl WorkloadTracker {
pub fn insert(&mut self, res_id: &ResidentId, config: &UserConfig, slot: Slot) { pub fn insert(&mut self, res_id: ResidentId, config: &UserConfig, slot: Slot) {
*self.total_counts.entry(res_id.clone()).or_insert(0) += 1; *self.total_counts.entry(res_id).or_insert(0) += 1;
*self *self
.type_counts .type_counts
.entry((res_id.clone(), slot.shift_type())) .entry((res_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.day.0) {
*self.holidays.entry(res_id.clone()).or_insert(0) += 1; *self.holidays.entry(res_id).or_insert(0) += 1;
} }
} }
pub fn remove(&mut self, resident_id: &ResidentId, config: &UserConfig, slot: Slot) { pub fn remove(&mut self, resident_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(&resident_id) {
*count = count.saturating_sub(1); *count = count.saturating_sub(1);
} }
if let Some(count) = self if let Some(count) = self.type_counts.get_mut(&(resident_id, slot.shift_type())) {
.type_counts
.get_mut(&(resident_id.clone(), 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.day.0) {
if let Some(count) = self.holidays.get_mut(resident_id) { if let Some(count) = self.holidays.get_mut(&resident_id) {
*count = count.saturating_sub(1); *count = count.saturating_sub(1);
} }
} }
@@ -250,14 +237,8 @@ impl WorkloadTracker {
for r in &config.residents { for r in &config.residents {
for shift_type in SHIFT_TYPES { for shift_type in SHIFT_TYPES {
let current_load = self let current_load = self.type_counts.get(&(r.id, shift_type)).unwrap_or(&0);
.type_counts if let Some(&min) = bounds.min_by_shift_type.get(&(r.id, shift_type)) {
.get(&(r.id.clone(), shift_type.clone()))
.unwrap_or(&0);
if let Some(&min) = bounds
.min_by_shift_type
.get(&(r.id.clone(), shift_type.clone()))
{
if *current_load < min { if *current_load < min {
return false; return false;
} }
@@ -308,21 +289,18 @@ impl WorkloadTracker {
let shift_type = slot.shift_type(); let shift_type = slot.shift_type();
let current_load = self let current_load = self
.type_counts .type_counts
.get(&(resident_id.clone(), shift_type.clone())) .get(&(*resident_id, shift_type))
.unwrap_or(&0); .unwrap_or(&0);
if let Some(&max) = bounds if let Some(&max) = bounds.max_by_shift_type.get(&(*resident_id, shift_type)) {
.max_by_shift_type
.get(&(resident_id.clone(), shift_type.clone()))
{
return *current_load > max; return *current_load > max;
} }
false false
} }
pub fn get_type_count(&self, res_id: &ResidentId, stype: ShiftType) -> u8 { pub fn get_type_count(&self, res_id: &ResidentId, shift_type: ShiftType) -> u8 {
*self.type_counts.get(&(res_id.clone(), stype)).unwrap_or(&0) *self.type_counts.get(&(*res_id, shift_type)).unwrap_or(&0)
} }
} }
@@ -339,11 +317,11 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Stefanos").with_max_shifts(2), Resident::new(1, "Stefanos").with_max_shifts(2),
Resident::new("2", "Iordanis").with_max_shifts(2), Resident::new(2, "Iordanis").with_max_shifts(2),
Resident::new("3", "Maria").with_reduced_load(), Resident::new(3, "Maria").with_reduced_load(),
Resident::new("4", "Veatriki"), Resident::new(4, "Veatriki"),
Resident::new("5", "Takis"), Resident::new(5, "Takis"),
]) ])
} }
@@ -356,52 +334,52 @@ mod tests {
fn test_max_workloads(config: UserConfig) { fn test_max_workloads(config: UserConfig) {
let bounds = WorkloadBounds::new_with_config(&config); let bounds = WorkloadBounds::new_with_config(&config);
assert_eq!(bounds.max_workloads[&ResidentId("1".to_string())], 2); assert_eq!(bounds.max_workloads[&ResidentId(1)], 2);
assert_eq!(bounds.max_workloads[&ResidentId("2".to_string())], 2); assert_eq!(bounds.max_workloads[&ResidentId(2)], 2);
assert!(bounds.max_workloads[&ResidentId("3".to_string())] > 0); assert!(bounds.max_workloads[&ResidentId(3)] > 0);
} }
#[rstest] #[rstest]
fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { fn test_is_total_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
let res_id = ResidentId("1".to_string()); let res_id = ResidentId(1);
let mut bounds = WorkloadBounds::default(); let mut bounds = WorkloadBounds::default();
bounds.max_workloads.insert(res_id.clone(), 1); bounds.max_workloads.insert(res_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); tracker.insert(res_id, &config, slot_1);
assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,)); assert!(!tracker.is_total_workload_exceeded(&bounds, &res_id,));
tracker.insert(&res_id, &config, slot_2); tracker.insert(res_id, &config, slot_2);
assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,)); assert!(tracker.is_total_workload_exceeded(&bounds, &res_id,));
} }
#[rstest] #[rstest]
fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) { fn test_is_holiday_workload_exceeded(mut tracker: WorkloadTracker, config: UserConfig) {
let res_id = ResidentId("1".to_string()); let res_id = ResidentId(1);
let mut bounds = WorkloadBounds::default(); let mut bounds = WorkloadBounds::default();
bounds.max_holiday_shifts.insert(res_id.clone(), 1); bounds.max_holiday_shifts.insert(res_id, 1);
let sat = Slot::new(Day(7), ShiftPosition::First); let sat = Slot::new(Day(7), ShiftPosition::First);
let sun = Slot::new(Day(8), ShiftPosition::First); let sun = Slot::new(Day(8), ShiftPosition::First);
tracker.insert(&res_id, &config, sat); tracker.insert(res_id, &config, sat);
assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id)); assert!(!tracker.is_holiday_workload_exceeded(&bounds, &res_id));
tracker.insert(&res_id, &config, sun); tracker.insert(res_id, &config, sun);
assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id)); assert!(tracker.is_holiday_workload_exceeded(&bounds, &res_id));
} }
#[rstest] #[rstest]
fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) { fn test_backtracking_accuracy(mut tracker: WorkloadTracker, config: UserConfig) {
let res_id = ResidentId("1".to_string()); let res_id = ResidentId(1);
let slot = Slot::new(Day(1), ShiftPosition::First); let slot = Slot::new(Day(1), ShiftPosition::First);
tracker.insert(&res_id, &config, slot); tracker.insert(res_id, &config, slot);
assert_eq!(tracker.current_workload(&res_id), 1); assert_eq!(tracker.current_workload(&res_id), 1);
tracker.remove(&res_id, &config, slot); tracker.remove(res_id, &config, slot);
assert_eq!(tracker.current_workload(&res_id), 0); assert_eq!(tracker.current_workload(&res_id), 0);
} }
} }

View File

@@ -13,10 +13,10 @@ mod integration_tests {
#[fixture] #[fixture]
fn minimal_config() -> UserConfig { fn minimal_config() -> UserConfig {
UserConfig::new(2).with_residents(vec![ UserConfig::new(2).with_residents(vec![
Resident::new("1", "R1"), Resident::new(1, "R1"),
Resident::new("2", "R2"), Resident::new(2, "R2"),
Resident::new("3", "R3"), Resident::new(3, "R3"),
Resident::new("4", "R4"), Resident::new(4, "R4"),
]) ])
} }
@@ -25,41 +25,41 @@ mod integration_tests {
UserConfig::new(2) UserConfig::new(2)
.with_holidays(vec![2, 3, 10, 11, 12, 25]) .with_holidays(vec![2, 3, 10, 11, 12, 25])
.with_residents(vec![ .with_residents(vec![
Resident::new("1", "R1").with_max_shifts(3), Resident::new(1, "R1").with_max_shifts(3),
Resident::new("2", "R2").with_max_shifts(4), Resident::new(2, "R2").with_max_shifts(4),
Resident::new("3", "R3").with_reduced_load(), Resident::new(3, "R3").with_reduced_load(),
Resident::new("4", "R4").with_allowed_types(vec![ShiftType::Closed]), Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
Resident::new("5", "R5") Resident::new(5, "R5")
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
Resident::new("6", "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]), Resident::new(6, "R6").with_negative_shifts(vec![Day(5), Day(15), Day(25)]),
Resident::new("7", "R7"), Resident::new(7, "R7"),
Resident::new("8", "R8"), Resident::new(8, "R8"),
Resident::new("9", "R9"), Resident::new(9, "R9"),
Resident::new("10", "R10"), Resident::new(10, "R10"),
]) ])
.with_toxic_pairs(vec![ .with_toxic_pairs(vec![
ToxicPair::new("1", "2"), ToxicPair::new(1, 2),
ToxicPair::new("3", "4"), ToxicPair::new(3, 4),
ToxicPair::new("7", "8"), ToxicPair::new(7, 8),
]) ])
} }
#[fixture] #[fixture]
fn manual_shifts_heavy_config() -> UserConfig { fn manual_shifts_heavy_config() -> UserConfig {
UserConfig::new(2).with_residents(vec![ UserConfig::new(2).with_residents(vec![
Resident::new("1", "R1").with_manual_shifts(vec![ Resident::new(1, "R1").with_manual_shifts(vec![
Slot::new(Day(1), ShiftPosition::First), Slot::new(Day(1), ShiftPosition::First),
Slot::new(Day(3), ShiftPosition::First), Slot::new(Day(3), ShiftPosition::First),
Slot::new(Day(5), ShiftPosition::Second), Slot::new(Day(5), ShiftPosition::Second),
]), ]),
Resident::new("2", "R2").with_manual_shifts(vec![ Resident::new(2, "R2").with_manual_shifts(vec![
Slot::new(Day(2), ShiftPosition::First), Slot::new(Day(2), ShiftPosition::First),
Slot::new(Day(4), ShiftPosition::First), Slot::new(Day(4), ShiftPosition::First),
]), ]),
Resident::new("3", "R3"), Resident::new(3, "R3"),
Resident::new("4", "R4"), Resident::new(4, "R4"),
Resident::new("5", "R5"), Resident::new(5, "R5"),
Resident::new("6", "R6"), Resident::new(6, "R6"),
]) ])
} }
@@ -68,27 +68,27 @@ mod integration_tests {
UserConfig::new(2) UserConfig::new(2)
.with_holidays(vec![5, 12, 19, 26]) .with_holidays(vec![5, 12, 19, 26])
.with_residents(vec![ .with_residents(vec![
Resident::new("1", "R1") Resident::new(1, "R1")
.with_max_shifts(3) .with_max_shifts(3)
.with_negative_shifts(vec![Day(1), Day(2), Day(3)]), .with_negative_shifts(vec![Day(1), Day(2), Day(3)]),
Resident::new("2", "R2") Resident::new(2, "R2")
.with_max_shifts(3) .with_max_shifts(3)
.with_negative_shifts(vec![Day(4), Day(5), Day(6)]), .with_negative_shifts(vec![Day(4), Day(5), Day(6)]),
Resident::new("3", "R3") Resident::new(3, "R3")
.with_max_shifts(3) .with_max_shifts(3)
.with_negative_shifts(vec![Day(7), Day(8), Day(9)]), .with_negative_shifts(vec![Day(7), Day(8), Day(9)]),
Resident::new("4", "R4").with_allowed_types(vec![ShiftType::Closed]), Resident::new(4, "R4").with_allowed_types(vec![ShiftType::Closed]),
Resident::new("5", "R5") Resident::new(5, "R5")
.with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]), .with_allowed_types(vec![ShiftType::OpenFirst, ShiftType::OpenSecond]),
Resident::new("6", "R6"), Resident::new(6, "R6"),
Resident::new("7", "R7"), Resident::new(7, "R7"),
Resident::new("8", "R8"), Resident::new(8, "R8"),
]) ])
.with_toxic_pairs(vec![ .with_toxic_pairs(vec![
ToxicPair::new("1", "2"), ToxicPair::new(1, 2),
ToxicPair::new("2", "3"), ToxicPair::new(2, 3),
ToxicPair::new("5", "6"), ToxicPair::new(5, 6),
ToxicPair::new("6", "7"), ToxicPair::new(6, 7),
]) ])
} }
@@ -160,7 +160,7 @@ mod integration_tests {
let r2 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::Second)); let r2 = schedule.get_resident_id(&Slot::new(day, ShiftPosition::Second));
assert_ne!(r1, r2); assert_ne!(r1, r2);
if let (Some(id1), Some(id2)) = (r1, r2) { if let (Some(id1), Some(id2)) = (r1, r2) {
let pair = ToxicPair::from((id1.clone(), id2.clone())); let pair = ToxicPair::from((*id1, *id2));
assert!(config.toxic_pairs.iter().all(|t| !t.matches(&pair))); assert!(config.toxic_pairs.iter().all(|t| !t.matches(&pair)));
} }
} }

View File

@@ -2,7 +2,7 @@
import { CalendarDate, getDayOfWeek } from "@internationalized/date"; import { CalendarDate, getDayOfWeek } from "@internationalized/date";
export interface Resident { export interface Resident {
id: string; id: number;
name: string; name: string;
negativeShifts: CalendarDate[]; negativeShifts: CalendarDate[];
manualShifts: CalendarDate[]; manualShifts: CalendarDate[];
@@ -12,12 +12,13 @@ export interface Resident {
} }
export interface ForbiddenPair { export interface ForbiddenPair {
id1: string; id1: number;
id2: string; id2: number;
} }
export class RotaState { export class RotaState {
currentStep = $state(1); currentStep = $state(1);
residentsCounter = $state(0);
residents = $state<Resident[]>([]); residents = $state<Resident[]>([]);
selectedMonth = $state(2); selectedMonth = $state(2);
selectedYear = $state(2026); selectedYear = $state(2026);
@@ -38,7 +39,7 @@ export class RotaState {
addResident() { addResident() {
this.residents.push({ this.residents.push({
id: crypto.randomUUID(), id: ++this.residentsCounter,
name: "", name: "",
negativeShifts: [], negativeShifts: [],
manualShifts: [], manualShifts: [],
@@ -48,11 +49,11 @@ export class RotaState {
}); });
} }
removeResident(id: string) { removeResident(id: number) {
this.residents = this.residents.filter((r) => r.id !== id); this.residents = this.residents.filter((r) => r.id !== id);
} }
findResident(id: string) { findResident(id: number) {
return this.residents.find((r) => r.id === id); return this.residents.find((r) => r.id === id);
} }
@@ -102,11 +103,11 @@ export type UserConfigDTO = {
year: number; year: number;
holidays: Array<number>; holidays: Array<number>;
residents: Array<ResidentDTO>; residents: Array<ResidentDTO>;
toxic_pairs: Array<[string, string]>; toxic_pairs: Array<[number, number]>;
}; };
export type ResidentDTO = { export type ResidentDTO = {
id: string; id: number;
name: string; name: string;
negativeShifts: Array<number>; negativeShifts: Array<number>;
manualShifts: Array<{ day: number; position: ShiftPosition }>; manualShifts: Array<{ day: number; position: ShiftPosition }>;