Add option to manually swap shifts after the schedule has been generated

This commit is contained in:
2026-07-02 19:15:07 +03:00
parent 515b9aa317
commit 9d8eacfb2b
7 changed files with 550 additions and 31 deletions

View File

@@ -47,6 +47,29 @@ pub enum ExportError {
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 {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where

View File

@@ -1,13 +1,15 @@
use std::sync::Mutex;
use log::info;
use log::{info, warn};
use serde::Serialize;
use crate::{
config::{UserConfig, UserConfigDTO},
errors::{ExportError, SearchError},
errors::{ExportError, SearchError, SwapError},
export::{Export, FileType},
schedule::{MonthlySchedule, ResidentMetrics},
scheduler::Scheduler,
slot::{Day, ShiftPosition, Slot},
workload::WorkloadTracker,
};
@@ -38,12 +40,13 @@ fn generate(
) -> Result<MonthlySchedule, SearchError> {
let mut schedule = MonthlySchedule::new();
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 solved = scheduler.run(&mut schedule, &mut tracker)?;
if !solved {
warn!("generate: no solution found");
return Err(SearchError::NoSolutionFound);
}
@@ -79,12 +82,19 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
let schedule = state.schedule.lock().unwrap();
let tracker = state.tracker.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);
schedule.export(FileType::Docx, config, &metrics)?;
schedule.export(FileType::Txt, config, &metrics)?;
schedule
.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("."));
info!("Files exported at {}", log_dir.display());
@@ -92,6 +102,94 @@ fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
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)]
pub fn run() {
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
@@ -114,7 +212,13 @@ pub fn run() {
.build(),
)
.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!())
.expect("Error while running tauri application");
}

View File

@@ -2,7 +2,8 @@ use serde::{ser::SerializeMap, Deserialize, Serialize};
use std::collections::HashMap;
use crate::{
config::UserConfig,
config::{ToxicPair, UserConfig},
errors::SwapError,
resident::ResidentId,
slot::{Day, ShiftPosition, Slot},
workload::WorkloadTracker,
@@ -55,7 +56,7 @@ impl MonthlySchedule {
}
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;
}
@@ -73,6 +74,131 @@ impl MonthlySchedule {
.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 {
let mut sorted: Vec<_> = self.0.iter().collect();
sorted.sort_by_key(|(slot, _)| (slot.day, slot.position));
@@ -178,9 +304,12 @@ mod tests {
use rstest::rstest;
use crate::{
config::{ToxicPair, UserConfig},
errors::SwapError,
resident::{Resident, ResidentId},
schedule::{Day, MonthlySchedule, Slot},
slot::ShiftPosition,
workload::WorkloadTracker,
};
#[rstest]
@@ -225,4 +354,143 @@ mod tests {
assert!(!schedule.has_resident_in_consecutive_days(&slot_2));
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());
}
}

View File

@@ -192,6 +192,27 @@ pub enum ShiftPosition {
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)]
mod tests {
use rstest::rstest;

View File

@@ -126,6 +126,36 @@ mod integration_tests {
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]
fn test_export_pipeline(minimal_config: UserConfig) -> anyhow::Result<()> {
let scheduler = Scheduler::new_with_config(minimal_config.clone());