Compare commits

...

2 Commits

10 changed files with 646 additions and 65 deletions

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# exported schedules
*.docx
rota.txt

View File

@@ -1,18 +1,54 @@
<h1 align="center">rota</h1> <h1 align="center">rota</h1>
Desktop app for generating monthly schedules for medical residents. The scheduling engine uses a parallel constraint-satisfaction DFS with a least-flexibility-first heuristic. <p align="center">
Desktop app for generating monthly shift schedules for medical residents.
</p>
### Features <p align="center">
- Configurable residents, shift types, and forbidden pairings <img alt="Tauri" src="https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white">
- Negative shift requests and manual pre-assignments <img alt="Rust" src="https://img.shields.io/badge/Rust-2021-000000?logo=rust&logoColor=white">
- Fairness-aware workload distribution <img alt="SvelteKit" src="https://img.shields.io/badge/SvelteKit-5-FF3E00?logo=svelte&logoColor=white">
- Export to `.docx` and `.txt` <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green">
</p>
### Stack ## Features
- **Frontend**: SvelteKit + TypeScript + Tailwind CSS
- **Backend**: Rust (Tauri)
### Usage - **Configurable roster**: residents, their allowed shift types, and per-resident shift caps
```bash - **Constraints**: forbidden pairings, day-off (negative) requests, and manual pre-assignments that the solver works around
just --list - **Fairness**: workload, weekend/holiday, and per-shift-type loads are balanced across the team
``` - **Interactive swaps**: after generating, ask the engine for all legal swaps for any shift and apply one without breaking constraints
- **Export**: one click writes the schedule and per-resident metrics to `.docx` and `.txt`
## How it works
A month is laid out as a sequence of **slots**. Days alternate between two kinds of duty:
| Day | Shift | Slots per day |
|-----|-------|---------------|
| odd | *open* | 2 |
| even | *closed* | 1 |
The solver fills every slot with exactly one resident subject to the hard constraints:
- no resident works two consecutive days
- forbidden ("toxic") pairs never share a day
- a resident is never placed on a day they requested off, or into a shift type they aren't allowed to take
- per-resident caps on total shifts, weekend/holiday shifts, and shifts of each type are respected
The search is a **backtracking DFS** over the slots, with a few tricks to make it fast and produce good schedules:
- **Least-flexibility-first**: at each slot, candidate residents are ordered by how few options they have left in the rest of the month, so the most constrained people get placed first (a fail-first / most-constrained heuristic).
- **Parallel roots**:the first slot's candidates are fanned out across threads with [rayon](https://github.com/rayon-rs/rayon); the first thread to find a complete schedule wins and signals the others to stop.
- **Timeout**: the search aborts after 5s and reports that no solution was found rather than hanging.
## Getting started
### Prerequisites
- [Rust](https://www.rust-lang.org/tools/install) (stable) and the [Tauri 2 system dependencies](https://tauri.app/start/prerequisites/) for your OS
- [Node.js](https://nodejs.org/) and [pnpm](https://pnpm.io/)
- [`just`](https://github.com/casey/just) (optional, for the shortcuts below)
## Commands
Run `just --list` to see every recipe.

View File

@@ -1,36 +1,58 @@
build: tauri_path := "src-tauri"
pnpm tauri build
# Launch the desktop app with hot reload
[group('dev')]
dev: dev:
pnpm tauri dev pnpm tauri dev
tauri_path := "src-tauri" # Produce a release bundle
[group('dev')]
build:
pnpm tauri build
# Run the Rust unit tests
[group('test')]
test:
cd {{tauri_path}} && cargo test --lib -- --test-threads=1
# Run the end-to-end solver tests
[group('test')]
test-integration:
cd {{tauri_path}} && cargo test --test integration -- --test-threads=1 --nocapture
# Run the full suite in release mode
[group('test')]
test-all:
cd {{tauri_path}} && cargo test --release
# Benchmark the parallel search (criterion)
[group('test')]
bench:
cd {{tauri_path}} && cargo bench
# Measure coverage via cargo-llvm-cov
[group('test')]
cov:
cd {{tauri_path}} && cargo llvm-cov run
# Run mutation testing via cargo-mutants
[group('test')]
mutants:
cd {{tauri_path}} && cargo mutants
# Format frontend (prettier) + backend (cargo fmt)
[group('quality')]
fmt: fmt:
pnpm prettier --write "**/*.svelte" "**/*.ts" pnpm prettier --write "**/*.svelte" "**/*.ts"
cd {{tauri_path}} && cargo fmt cd {{tauri_path}} && cargo fmt
# Lint the backend (cargo clippy)
[group('quality')]
lint: lint:
cd {{tauri_path}} && cargo clippy cd {{tauri_path}} && cargo clippy
test: # Remove node_modules and cargo build artifacts
cd {{tauri_path}} && cargo test --lib -- --test-threads=1 [group('misc')]
test-integration:
cd {{tauri_path}} && cargo test --test integration -- --test-threads=1 --nocapture
test-all:
cd {{tauri_path}} && cargo test --release
bench:
cd {{tauri_path}} && cargo bench
cov:
cd {{tauri_path}} && cargo llvm-cov run
mutants:
cd {{tauri_path}} && cargo mutants
clean: clean:
rm -rf node_modules rm -rf node_modules
cd {{tauri_path}} && cargo clean cd {{tauri_path}} && cargo clean

View File

@@ -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

View File

@@ -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");
} }

View File

@@ -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());
}
} }

View File

@@ -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;

View File

@@ -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());

View File

@@ -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}

View File

@@ -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") }));