Add option to manually swap shifts after the schedule has been generated
This commit is contained in:
@@ -47,6 +47,29 @@ pub enum ExportError {
|
|||||||
OpenFailed(String),
|
OpenFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug, Serialize)]
|
||||||
|
#[serde(tag = "kind", content = "details")]
|
||||||
|
pub enum SwapError {
|
||||||
|
#[error("no schedule has been generated yet")]
|
||||||
|
NotGenerated,
|
||||||
|
#[error("invalid shift position: {0}")]
|
||||||
|
InvalidPosition(String),
|
||||||
|
#[error("slot has no assigned resident")]
|
||||||
|
SlotEmpty,
|
||||||
|
#[error("both slots belong to the same resident")]
|
||||||
|
SameResident,
|
||||||
|
#[error("resident not found in config")]
|
||||||
|
ResidentNotFound,
|
||||||
|
#[error("resident cannot take the target slot: negative shift or disallowed type")]
|
||||||
|
ResidentCannotTakeSlot,
|
||||||
|
#[error("swap violates same-day or toxic pair constraint")]
|
||||||
|
SameDayOrToxicPair,
|
||||||
|
#[error("swap creates a consecutive-day constraint violation")]
|
||||||
|
ConsecutiveDays,
|
||||||
|
#[error("cannot swap the same slot or slots on the same day")]
|
||||||
|
SameSlotOrDay,
|
||||||
|
}
|
||||||
|
|
||||||
impl Serialize for ExportError {
|
impl Serialize for ExportError {
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use log::info;
|
use log::{info, warn};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{UserConfig, UserConfigDTO},
|
config::{UserConfig, UserConfigDTO},
|
||||||
errors::{ExportError, SearchError},
|
errors::{ExportError, SearchError, SwapError},
|
||||||
export::{Export, FileType},
|
export::{Export, FileType},
|
||||||
schedule::{MonthlySchedule, ResidentMetrics},
|
schedule::{MonthlySchedule, ResidentMetrics},
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::WorkloadTracker,
|
workload::WorkloadTracker,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,12 +40,13 @@ fn generate(
|
|||||||
) -> Result<MonthlySchedule, SearchError> {
|
) -> Result<MonthlySchedule, SearchError> {
|
||||||
let mut schedule = MonthlySchedule::new();
|
let mut schedule = MonthlySchedule::new();
|
||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
let config = UserConfig::try_from(config)?;
|
let config = UserConfig::try_from(config).inspect_err(|e| warn!("generate: {e}"))?;
|
||||||
let scheduler = Scheduler::new_with_config(config.clone());
|
let scheduler = Scheduler::new_with_config(config.clone());
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
|
|
||||||
if !solved {
|
if !solved {
|
||||||
|
warn!("generate: no solution found");
|
||||||
return Err(SearchError::NoSolutionFound);
|
return Err(SearchError::NoSolutionFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,12 +82,19 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
|||||||
let schedule = state.schedule.lock().unwrap();
|
let schedule = state.schedule.lock().unwrap();
|
||||||
let tracker = state.tracker.lock().unwrap();
|
let tracker = state.tracker.lock().unwrap();
|
||||||
let config = state.config.lock().unwrap();
|
let config = state.config.lock().unwrap();
|
||||||
let config = config.as_ref().ok_or(ExportError::NotGenerated)?;
|
let config = config
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(ExportError::NotGenerated)
|
||||||
|
.inspect_err(|e| warn!("export: {e}"))?;
|
||||||
|
|
||||||
let metrics = schedule.metrics(config, &tracker);
|
let metrics = schedule.metrics(config, &tracker);
|
||||||
|
|
||||||
schedule.export(FileType::Docx, config, &metrics)?;
|
schedule
|
||||||
schedule.export(FileType::Txt, config, &metrics)?;
|
.export(FileType::Docx, config, &metrics)
|
||||||
|
.inspect_err(|e| warn!("export: {e}"))?;
|
||||||
|
schedule
|
||||||
|
.export(FileType::Txt, config, &metrics)
|
||||||
|
.inspect_err(|e| warn!("export: {e}"))?;
|
||||||
|
|
||||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||||
info!("Files exported at {}", log_dir.display());
|
info!("Files exported at {}", log_dir.display());
|
||||||
@@ -92,6 +102,94 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SwapOption {
|
||||||
|
resident_name: String,
|
||||||
|
slot_day: u8,
|
||||||
|
slot_position: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn find_possible_swaps(
|
||||||
|
slot_day: u8,
|
||||||
|
slot_position: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Vec<SwapOption> {
|
||||||
|
let schedule = state.schedule.lock().unwrap();
|
||||||
|
let config = state.config.lock().unwrap();
|
||||||
|
|
||||||
|
if let (Some(config), Ok(position)) = (
|
||||||
|
config.as_ref(),
|
||||||
|
ShiftPosition::try_from(slot_position.as_str()),
|
||||||
|
) {
|
||||||
|
let options: Vec<SwapOption> = schedule
|
||||||
|
.possible_swaps(Slot::new(Day(slot_day), position), config)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(slot_b, resident_b_id)| {
|
||||||
|
let name = config
|
||||||
|
.residents
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == resident_b_id)?
|
||||||
|
.name
|
||||||
|
.clone();
|
||||||
|
Some(SwapOption {
|
||||||
|
resident_name: name,
|
||||||
|
slot_day: slot_b.day.0,
|
||||||
|
slot_position: slot_b.position.as_str().to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
info!(
|
||||||
|
"Found {} swap options for slot day={} position={}",
|
||||||
|
options.len(),
|
||||||
|
slot_day,
|
||||||
|
slot_position
|
||||||
|
);
|
||||||
|
options
|
||||||
|
} else {
|
||||||
|
warn!("find_possible_swaps: no config loaded or invalid position '{slot_position}'");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn perform_swap(
|
||||||
|
slot_a_day: u8,
|
||||||
|
slot_a_position: String,
|
||||||
|
slot_b_day: u8,
|
||||||
|
slot_b_position: String,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<MonthlySchedule, SwapError> {
|
||||||
|
let mut schedule = state.schedule.lock().unwrap();
|
||||||
|
let mut tracker = state.tracker.lock().unwrap();
|
||||||
|
let config = state.config.lock().unwrap();
|
||||||
|
let config = config
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(SwapError::NotGenerated)
|
||||||
|
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||||
|
|
||||||
|
let pos_a = ShiftPosition::try_from(slot_a_position.as_str())
|
||||||
|
.map_err(|_| SwapError::InvalidPosition(slot_a_position.clone()))
|
||||||
|
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||||
|
let pos_b = ShiftPosition::try_from(slot_b_position.as_str())
|
||||||
|
.map_err(|_| SwapError::InvalidPosition(slot_b_position.clone()))
|
||||||
|
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||||
|
|
||||||
|
let slot_a = Slot::new(Day(slot_a_day), pos_a);
|
||||||
|
let slot_b = Slot::new(Day(slot_b_day), pos_b);
|
||||||
|
|
||||||
|
schedule
|
||||||
|
.apply_swap(slot_a, slot_b, config, &mut tracker)
|
||||||
|
.inspect_err(|e| warn!("perform_swap: {e}"))?;
|
||||||
|
info!(
|
||||||
|
"Swap performed: day {}/{} ↔ day {}/{}",
|
||||||
|
slot_a_day, slot_a_position, slot_b_day, slot_b_position
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(schedule.clone())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||||
@@ -114,7 +212,13 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![generate, export, get_metrics])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
generate,
|
||||||
|
export,
|
||||||
|
get_metrics,
|
||||||
|
find_possible_swaps,
|
||||||
|
perform_swap
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error while running tauri application");
|
.expect("Error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use serde::{ser::SerializeMap, Deserialize, Serialize};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::{ToxicPair, UserConfig},
|
||||||
|
errors::SwapError,
|
||||||
resident::ResidentId,
|
resident::ResidentId,
|
||||||
slot::{Day, ShiftPosition, Slot},
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::WorkloadTracker,
|
workload::WorkloadTracker,
|
||||||
@@ -55,7 +56,7 @@ impl MonthlySchedule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
|
pub fn has_resident_in_consecutive_days(&self, slot: &Slot) -> bool {
|
||||||
if slot.day == Day(1) {
|
if slot.day == Day(1) || self.get_resident_id(slot).is_none() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +74,131 @@ impl MonthlySchedule {
|
|||||||
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot))
|
.any(|s| self.get_resident_id(s) == self.get_resident_id(slot))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_swap(&self, slot_a: Slot, slot_b: Slot, config: &UserConfig) -> Result<(), SwapError> {
|
||||||
|
let resident_a_id = *self.get_resident_id(&slot_a).ok_or(SwapError::SlotEmpty)?;
|
||||||
|
let resident_b_id = *self.get_resident_id(&slot_b).ok_or(SwapError::SlotEmpty)?;
|
||||||
|
|
||||||
|
if resident_a_id == resident_b_id {
|
||||||
|
return Err(SwapError::SameResident);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resident_a = config
|
||||||
|
.residents
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == resident_a_id)
|
||||||
|
.ok_or(SwapError::ResidentNotFound)?;
|
||||||
|
let resident_b = config
|
||||||
|
.residents
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == resident_b_id)
|
||||||
|
.ok_or(SwapError::ResidentNotFound)?;
|
||||||
|
|
||||||
|
if resident_b.negative_shifts.contains(&slot_a.day)
|
||||||
|
|| !resident_b.allowed_types.contains(&slot_a.shift_type())
|
||||||
|
|| resident_a.negative_shifts.contains(&slot_b.day)
|
||||||
|
|| !resident_a.allowed_types.contains(&slot_b.shift_type())
|
||||||
|
{
|
||||||
|
return Err(SwapError::ResidentCannotTakeSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(partner_a) = slot_a.other_position() {
|
||||||
|
if let Some(&other_id) = self.get_resident_id(&partner_a) {
|
||||||
|
if other_id == resident_b_id
|
||||||
|
|| config
|
||||||
|
.toxic_pairs
|
||||||
|
.iter()
|
||||||
|
.any(|tp| tp.matches(&ToxicPair::from((resident_b_id, other_id))))
|
||||||
|
{
|
||||||
|
return Err(SwapError::SameDayOrToxicPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(partner_b) = slot_b.other_position() {
|
||||||
|
if let Some(&other_id) = self.get_resident_id(&partner_b) {
|
||||||
|
if other_id == resident_a_id
|
||||||
|
|| config
|
||||||
|
.toxic_pairs
|
||||||
|
.iter()
|
||||||
|
.any(|tp| tp.matches(&ToxicPair::from((resident_a_id, other_id))))
|
||||||
|
{
|
||||||
|
return Err(SwapError::SameDayOrToxicPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut temp = self.clone();
|
||||||
|
temp.remove(slot_a);
|
||||||
|
temp.remove(slot_b);
|
||||||
|
temp.insert(slot_a, resident_b_id);
|
||||||
|
temp.insert(slot_b, resident_a_id);
|
||||||
|
|
||||||
|
let violated = [slot_a.day, slot_a.day.next(), slot_b.day, slot_b.day.next()]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&day| {
|
||||||
|
[ShiftPosition::First, ShiftPosition::Second]
|
||||||
|
.iter()
|
||||||
|
.map(move |&p| Slot::new(day, p))
|
||||||
|
})
|
||||||
|
.any(|s| temp.has_resident_in_consecutive_days(&s));
|
||||||
|
|
||||||
|
if violated {
|
||||||
|
return Err(SwapError::ConsecutiveDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn possible_swaps(&self, slot_a: Slot, config: &UserConfig) -> Vec<(Slot, ResidentId)> {
|
||||||
|
let Some(&resident_a_id) = self.get_resident_id(&slot_a) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut results: Vec<(Slot, ResidentId)> = self
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.filter(|(&slot_b, &resident_b_id)| {
|
||||||
|
slot_b != slot_a
|
||||||
|
&& slot_b.day != slot_a.day
|
||||||
|
&& resident_b_id != resident_a_id
|
||||||
|
&& self.check_swap(slot_a, slot_b, config).is_ok()
|
||||||
|
})
|
||||||
|
.map(|(&slot_b, &resident_b_id)| (slot_b, resident_b_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
results.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_swap(
|
||||||
|
&mut self,
|
||||||
|
slot_a: Slot,
|
||||||
|
slot_b: Slot,
|
||||||
|
config: &UserConfig,
|
||||||
|
tracker: &mut WorkloadTracker,
|
||||||
|
) -> Result<(), SwapError> {
|
||||||
|
if slot_a == slot_b || slot_a.day == slot_b.day {
|
||||||
|
return Err(SwapError::SameSlotOrDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.check_swap(slot_a, slot_b, config)?;
|
||||||
|
|
||||||
|
let resident_a_id = *self.get_resident_id(&slot_a).unwrap();
|
||||||
|
let resident_b_id = *self.get_resident_id(&slot_b).unwrap();
|
||||||
|
|
||||||
|
self.remove(slot_a);
|
||||||
|
self.remove(slot_b);
|
||||||
|
self.insert(slot_a, resident_b_id);
|
||||||
|
self.insert(slot_b, resident_a_id);
|
||||||
|
|
||||||
|
tracker.remove(resident_a_id, config, slot_a);
|
||||||
|
tracker.remove(resident_b_id, config, slot_b);
|
||||||
|
tracker.insert(resident_b_id, config, slot_a);
|
||||||
|
tracker.insert(resident_a_id, config, slot_b);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
pub fn pretty_print(&self, config: &UserConfig) -> String {
|
||||||
let mut sorted: Vec<_> = self.0.iter().collect();
|
let mut sorted: Vec<_> = self.0.iter().collect();
|
||||||
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
|
||||||
@@ -178,9 +304,12 @@ mod tests {
|
|||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
config::{ToxicPair, UserConfig},
|
||||||
|
errors::SwapError,
|
||||||
resident::{Resident, ResidentId},
|
resident::{Resident, ResidentId},
|
||||||
schedule::{Day, MonthlySchedule, Slot},
|
schedule::{Day, MonthlySchedule, Slot},
|
||||||
slot::ShiftPosition,
|
slot::ShiftPosition,
|
||||||
|
workload::WorkloadTracker,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
@@ -225,4 +354,143 @@ mod tests {
|
|||||||
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
|
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
|
||||||
assert!(schedule.has_resident_in_consecutive_days(&slot_3));
|
assert!(schedule.has_resident_in_consecutive_days(&slot_3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_apply_swap_success() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||||
|
let r1 = config.residents[0].id;
|
||||||
|
let r2 = config.residents[1].id;
|
||||||
|
schedule.insert(slot_a, r1);
|
||||||
|
tracker.insert(r1, &config, slot_a);
|
||||||
|
schedule.insert(slot_b, r2);
|
||||||
|
tracker.insert(r2, &config, slot_b);
|
||||||
|
|
||||||
|
schedule
|
||||||
|
.apply_swap(slot_a, slot_b, &config, &mut tracker)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(schedule.get_resident_id(&slot_a), Some(&r2));
|
||||||
|
assert_eq!(schedule.get_resident_id(&slot_b), Some(&r1));
|
||||||
|
assert_eq!(tracker.current_workload(&r1), 1);
|
||||||
|
assert_eq!(tracker.current_workload(&r2), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_apply_swap_same_slot_error() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
schedule.insert(slot_a, config.residents[0].id);
|
||||||
|
schedule.insert(
|
||||||
|
Slot::new(Day(4), ShiftPosition::First),
|
||||||
|
config.residents[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = schedule.apply_swap(slot_a, slot_a, &config, &mut tracker);
|
||||||
|
assert!(matches!(result, Err(SwapError::SameSlotOrDay)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_apply_swap_negative_shift_error() {
|
||||||
|
let config = UserConfig::default().with_residents(vec![
|
||||||
|
Resident::new(1, "R1").with_negative_shifts(vec![Day(4)]),
|
||||||
|
Resident::new(2, "R2"),
|
||||||
|
]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||||
|
schedule.insert(slot_a, config.residents[0].id);
|
||||||
|
schedule.insert(slot_b, config.residents[1].id);
|
||||||
|
|
||||||
|
// R1 has a negative shift on Day(4) so it cannot take slot_b
|
||||||
|
let result = schedule.apply_swap(slot_a, slot_b, &config, &mut tracker);
|
||||||
|
assert!(matches!(result, Err(SwapError::ResidentCannotTakeSlot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_apply_swap_toxic_pair_error() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![
|
||||||
|
Resident::new(1, "R1"),
|
||||||
|
Resident::new(2, "R2"),
|
||||||
|
Resident::new(3, "R3"),
|
||||||
|
])
|
||||||
|
.with_toxic_pairs(vec![ToxicPair::new(2, 3)]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
// Day(1) is open — two slots. R1 on First, R3 on Second
|
||||||
|
let slot_1a = Slot::new(Day(1), ShiftPosition::First);
|
||||||
|
let slot_1b = Slot::new(Day(1), ShiftPosition::Second);
|
||||||
|
let slot_3 = Slot::new(Day(3), ShiftPosition::First);
|
||||||
|
schedule.insert(slot_1a, config.residents[0].id); // R1
|
||||||
|
schedule.insert(slot_1b, config.residents[2].id); // R3
|
||||||
|
schedule.insert(slot_3, config.residents[1].id); // R2
|
||||||
|
|
||||||
|
// Swapping R1 (Day1/First) with R2 (Day3/First) would put R2 alongside R3 — toxic pair
|
||||||
|
let result = schedule.apply_swap(slot_1a, slot_3, &config, &mut tracker);
|
||||||
|
assert!(matches!(result, Err(SwapError::SameDayOrToxicPair)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_apply_swap_consecutive_days_error() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||||
|
let slot_c = Slot::new(Day(3), ShiftPosition::First);
|
||||||
|
// R2 on Day(3) and Day(4). Swapping R1 (Day2) ↔ R2 (Day4)
|
||||||
|
// would put R2 on Day(2) consecutive with Day(3)
|
||||||
|
schedule.insert(slot_a, config.residents[0].id); // R1
|
||||||
|
schedule.insert(slot_b, config.residents[1].id); // R2
|
||||||
|
schedule.insert(slot_c, config.residents[1].id); // R2 also on Day3
|
||||||
|
|
||||||
|
let result = schedule.apply_swap(slot_a, slot_b, &config, &mut tracker);
|
||||||
|
assert!(matches!(result, Err(SwapError::ConsecutiveDays)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_possible_swaps_returns_candidate() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let slot_a = Slot::new(Day(2), ShiftPosition::First);
|
||||||
|
let slot_b = Slot::new(Day(4), ShiftPosition::First);
|
||||||
|
schedule.insert(slot_a, config.residents[0].id);
|
||||||
|
schedule.insert(slot_b, config.residents[1].id);
|
||||||
|
|
||||||
|
let swaps = schedule.possible_swaps(slot_a, &config);
|
||||||
|
|
||||||
|
assert_eq!(swaps.len(), 1);
|
||||||
|
assert_eq!(swaps[0].0, slot_b);
|
||||||
|
assert_eq!(swaps[0].1, config.residents[1].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_possible_swaps_empty_slot_returns_empty() {
|
||||||
|
let config = UserConfig::default()
|
||||||
|
.with_residents(vec![Resident::new(1, "R1"), Resident::new(2, "R2")]);
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
schedule.insert(
|
||||||
|
Slot::new(Day(2), ShiftPosition::First),
|
||||||
|
config.residents[0].id,
|
||||||
|
);
|
||||||
|
schedule.insert(
|
||||||
|
Slot::new(Day(4), ShiftPosition::First),
|
||||||
|
config.residents[1].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(schedule
|
||||||
|
.possible_swaps(Slot::new(Day(6), ShiftPosition::First), &config)
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,27 @@ pub enum ShiftPosition {
|
|||||||
Second,
|
Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ShiftPosition {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ShiftPosition::First => "First",
|
||||||
|
ShiftPosition::Second => "Second",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for ShiftPosition {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||||
|
match s {
|
||||||
|
"First" => Ok(ShiftPosition::First),
|
||||||
|
"Second" => Ok(ShiftPosition::Second),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
|
|||||||
@@ -126,6 +126,36 @@ mod integration_tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn test_swap_preserves_constraints(
|
||||||
|
#[values(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)] month_idx: u8,
|
||||||
|
mut minimal_config: UserConfig,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
minimal_config.update_month(month_idx);
|
||||||
|
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||||
|
let mut schedule = MonthlySchedule::new();
|
||||||
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
assert!(scheduler.run(&mut schedule, &mut tracker)?);
|
||||||
|
|
||||||
|
let slots: Vec<_> = schedule.0.keys().copied().collect();
|
||||||
|
let mut swapped = false;
|
||||||
|
for slot in slots {
|
||||||
|
let candidates = schedule.possible_swaps(slot, &minimal_config);
|
||||||
|
if let Some(&(swap_target, _)) = candidates.first() {
|
||||||
|
schedule
|
||||||
|
.apply_swap(slot, swap_target, &minimal_config, &mut tracker)
|
||||||
|
.expect("apply_swap failed for a candidate returned by possible_swaps");
|
||||||
|
swapped = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(swapped, "No valid swap found in generated schedule");
|
||||||
|
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn test_export_pipeline(minimal_config: UserConfig) -> anyhow::Result<()> {
|
fn test_export_pipeline(minimal_config: UserConfig) -> anyhow::Result<()> {
|
||||||
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
let scheduler = Scheduler::new_with_config(minimal_config.clone());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
EngineStatus,
|
EngineStatus,
|
||||||
|
type MonthlySchedule,
|
||||||
type MonthlyScheduleDTO,
|
type MonthlyScheduleDTO,
|
||||||
type ResidentMetrics,
|
type ResidentMetrics,
|
||||||
rota,
|
rota,
|
||||||
@@ -10,12 +11,28 @@
|
|||||||
type ShiftPosition
|
type ShiftPosition
|
||||||
} from "../../state.svelte.js";
|
} from "../../state.svelte.js";
|
||||||
|
|
||||||
|
interface SwapOption {
|
||||||
|
residentName: string;
|
||||||
|
slotDay: number;
|
||||||
|
slotPosition: ShiftPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppError {
|
||||||
|
kind: string;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedSlot = $state<{ day: number; position: ShiftPosition } | null>(null);
|
||||||
|
let swapOptions = $state<SwapOption[]>([]);
|
||||||
|
let loadingSwaps = $state(false);
|
||||||
|
|
||||||
|
let validSwapKeys = $derived(new Set(swapOptions.map((o) => `${o.slotDay}-${o.slotPosition}`)));
|
||||||
|
|
||||||
function getResidentName(day: number, pos: ShiftPosition) {
|
function getResidentName(day: number, pos: ShiftPosition) {
|
||||||
const residentId = rota.solution[`${day}-${pos}`];
|
const residentId = rota.solution[`${day}-${pos}`];
|
||||||
const r = rota.findResident(residentId);
|
const r = rota.findResident(residentId);
|
||||||
if (r) return r.name;
|
if (r) return r.name;
|
||||||
|
|
||||||
// check for manual
|
|
||||||
const assignedResidents = rota.residents.filter((resident) =>
|
const assignedResidents = rota.residents.filter((resident) =>
|
||||||
resident.manualShifts.some(
|
resident.manualShifts.some(
|
||||||
(shift) =>
|
(shift) =>
|
||||||
@@ -25,31 +42,93 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
let slot;
|
const slot = pos === "First" ? 1 : 2;
|
||||||
if (pos == "First") {
|
|
||||||
slot = 1;
|
|
||||||
} else {
|
|
||||||
slot = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resident = assignedResidents[slot - 1];
|
const resident = assignedResidents[slot - 1];
|
||||||
return resident ? resident.name : "-";
|
return resident ? resident.name : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2 slots in odd days, 1 slot in even days
|
|
||||||
function getRequiredSlots(day: number) {
|
function getRequiredSlots(day: number) {
|
||||||
return day % 2 === 0 ? 1 : 2;
|
return day % 2 === 0 ? 1 : 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppError {
|
function isSelected(day: number, position: ShiftPosition): boolean {
|
||||||
kind: string;
|
return selectedSlot?.day === day && selectedSlot?.position === position;
|
||||||
details: string;
|
}
|
||||||
|
|
||||||
|
function isValidSwap(day: number, position: ShiftPosition): boolean {
|
||||||
|
return validSwapKeys.has(`${day}-${position}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellClass(day: number, position: ShiftPosition): string {
|
||||||
|
const isFirst = position === "First";
|
||||||
|
const base =
|
||||||
|
"w-full overflow-hidden rounded border px-1.5 py-1 text-center text-[10px] font-bold transition-colors";
|
||||||
|
|
||||||
|
if (isSelected(day, position)) {
|
||||||
|
return isFirst
|
||||||
|
? `${base} border-rose-300 bg-rose-200 text-rose-900`
|
||||||
|
: `${base} border-emerald-300 bg-emerald-200 text-emerald-900`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normal = isFirst
|
||||||
|
? `${base} border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100`
|
||||||
|
: `${base} border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100`;
|
||||||
|
|
||||||
|
if (selectedSlot !== null && !isValidSwap(day, position)) {
|
||||||
|
return `${normal} opacity-30 pointer-events-none`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCellClick(day: number, position: ShiftPosition) {
|
||||||
|
if (isSelected(day, position)) {
|
||||||
|
selectedSlot = null;
|
||||||
|
swapOptions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSlot !== null && isValidSwap(day, position)) {
|
||||||
|
try {
|
||||||
|
rota.solution = await invoke<MonthlySchedule>("perform_swap", {
|
||||||
|
slotADay: selectedSlot.day,
|
||||||
|
slotAPosition: selectedSlot.position,
|
||||||
|
slotBDay: day,
|
||||||
|
slotBPosition: position
|
||||||
|
});
|
||||||
|
rota.metrics = await invoke<ResidentMetrics[]>("get_metrics");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Swap failed:", error);
|
||||||
|
} finally {
|
||||||
|
selectedSlot = null;
|
||||||
|
swapOptions = [];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise select this cell and find possible swaps
|
||||||
|
selectedSlot = { day, position };
|
||||||
|
swapOptions = [];
|
||||||
|
loadingSwaps = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
swapOptions = await invoke<SwapOption[]>("find_possible_swaps", {
|
||||||
|
slotDay: day,
|
||||||
|
slotPosition: position
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to find swaps:", error);
|
||||||
|
} finally {
|
||||||
|
loadingSwaps = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
let config = rota.toDTO();
|
let config = rota.toDTO();
|
||||||
rota.engineStatus = EngineStatus.Running;
|
rota.engineStatus = EngineStatus.Running;
|
||||||
rota.lastMessage = "";
|
rota.lastMessage = "";
|
||||||
|
selectedSlot = null;
|
||||||
|
swapOptions = [];
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,25 +182,19 @@
|
|||||||
{#each rota.emptySlots as _}<div class="min-h-25 bg-zinc-100/60"></div>{/each}
|
{#each rota.emptySlots as _}<div class="min-h-25 bg-zinc-100/60"></div>{/each}
|
||||||
{#each rota.daysArray as day (day)}
|
{#each rota.daysArray as day (day)}
|
||||||
{@const slotCount = getRequiredSlots(day)}
|
{@const slotCount = getRequiredSlots(day)}
|
||||||
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-teal-50/30">
|
<div class="min-h-25 bg-white p-2">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
<span class="text-xs font-black text-zinc-500">{day}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#if slotCount > 0}
|
{#if slotCount > 0}
|
||||||
<button
|
<button onclick={() => handleCellClick(day, "First")} class={cellClass(day, "First")}>
|
||||||
onclick={() => "handleCellClick(day, 1)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-rose-200 bg-rose-50 px-1.5 py-1 text-left text-[10px] font-bold text-rose-700 transition-colors hover:bg-rose-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, "First")}
|
{getResidentName(day, "First")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if slotCount > 1}
|
{#if slotCount > 1}
|
||||||
<button
|
<button onclick={() => handleCellClick(day, "Second")} class={cellClass(day, "Second")}>
|
||||||
onclick={() => "handleCellClick(day, 2)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-700 transition-colors hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, "Second")}
|
{getResidentName(day, "Second")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class RotaState {
|
|||||||
|
|
||||||
projectMonth = $derived(new CalendarDate(this.selectedYear, this.selectedMonth, 1));
|
projectMonth = $derived(new CalendarDate(this.selectedYear, this.selectedMonth, 1));
|
||||||
projectMonthDays = $derived(this.projectMonth.calendar.getDaysInMonth(this.projectMonth));
|
projectMonthDays = $derived(this.projectMonth.calendar.getDaysInMonth(this.projectMonth));
|
||||||
|
|
||||||
daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1));
|
daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1));
|
||||||
emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") }));
|
emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user