Compare commits
2 Commits
515b9aa317
...
e43ebf250e
| Author | SHA1 | Date | |
|---|---|---|---|
| e43ebf250e | |||
| 9d8eacfb2b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# exported schedules
|
||||
*.docx
|
||||
rota.txt
|
||||
|
||||
62
README.md
62
README.md
@@ -1,18 +1,54 @@
|
||||
<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
|
||||
- Configurable residents, shift types, and forbidden pairings
|
||||
- Negative shift requests and manual pre-assignments
|
||||
- Fairness-aware workload distribution
|
||||
- Export to `.docx` and `.txt`
|
||||
<p align="center">
|
||||
<img alt="Tauri" src="https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white">
|
||||
<img alt="Rust" src="https://img.shields.io/badge/Rust-2021-000000?logo=rust&logoColor=white">
|
||||
<img alt="SvelteKit" src="https://img.shields.io/badge/SvelteKit-5-FF3E00?logo=svelte&logoColor=white">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green">
|
||||
</p>
|
||||
|
||||
### Stack
|
||||
- **Frontend**: SvelteKit + TypeScript + Tailwind CSS
|
||||
- **Backend**: Rust (Tauri)
|
||||
## Features
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
just --list
|
||||
```
|
||||
- **Configurable roster**: residents, their allowed shift types, and per-resident shift caps
|
||||
- **Constraints**: forbidden pairings, day-off (negative) requests, and manual pre-assignments that the solver works around
|
||||
- **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.
|
||||
|
||||
64
justfile
64
justfile
@@ -1,36 +1,58 @@
|
||||
build:
|
||||
pnpm tauri build
|
||||
tauri_path := "src-tauri"
|
||||
|
||||
# Launch the desktop app with hot reload
|
||||
[group('dev')]
|
||||
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:
|
||||
pnpm prettier --write "**/*.svelte" "**/*.ts"
|
||||
cd {{tauri_path}} && cargo fmt
|
||||
|
||||
# Lint the backend (cargo clippy)
|
||||
[group('quality')]
|
||||
lint:
|
||||
cd {{tauri_path}} && cargo clippy
|
||||
|
||||
test:
|
||||
cd {{tauri_path}} && cargo test --lib -- --test-threads=1
|
||||
|
||||
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
|
||||
|
||||
# Remove node_modules and cargo build artifacts
|
||||
[group('misc')]
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
cd {{tauri_path}} && cargo clean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
EngineStatus,
|
||||
type MonthlySchedule,
|
||||
type MonthlyScheduleDTO,
|
||||
type ResidentMetrics,
|
||||
rota,
|
||||
@@ -10,12 +11,28 @@
|
||||
type ShiftPosition
|
||||
} 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) {
|
||||
const residentId = rota.solution[`${day}-${pos}`];
|
||||
const r = rota.findResident(residentId);
|
||||
if (r) return r.name;
|
||||
|
||||
// check for manual
|
||||
const assignedResidents = rota.residents.filter((resident) =>
|
||||
resident.manualShifts.some(
|
||||
(shift) =>
|
||||
@@ -25,31 +42,93 @@
|
||||
)
|
||||
);
|
||||
|
||||
let slot;
|
||||
if (pos == "First") {
|
||||
slot = 1;
|
||||
} else {
|
||||
slot = 2;
|
||||
}
|
||||
|
||||
const slot = pos === "First" ? 1 : 2;
|
||||
const resident = assignedResidents[slot - 1];
|
||||
return resident ? resident.name : "-";
|
||||
}
|
||||
|
||||
// 2 slots in odd days, 1 slot in even days
|
||||
function getRequiredSlots(day: number) {
|
||||
return day % 2 === 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
interface AppError {
|
||||
kind: string;
|
||||
details: string;
|
||||
function isSelected(day: number, position: ShiftPosition): boolean {
|
||||
return selectedSlot?.day === day && selectedSlot?.position === position;
|
||||
}
|
||||
|
||||
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() {
|
||||
let config = rota.toDTO();
|
||||
rota.engineStatus = EngineStatus.Running;
|
||||
rota.lastMessage = "";
|
||||
selectedSlot = null;
|
||||
swapOptions = [];
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
try {
|
||||
@@ -103,25 +182,19 @@
|
||||
{#each rota.emptySlots as _}<div class="min-h-25 bg-zinc-100/60"></div>{/each}
|
||||
{#each rota.daysArray as day (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">
|
||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if slotCount > 0}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<button onclick={() => handleCellClick(day, "First")} class={cellClass(day, "First")}>
|
||||
{getResidentName(day, "First")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if slotCount > 1}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<button onclick={() => handleCellClick(day, "Second")} class={cellClass(day, "Second")}>
|
||||
{getResidentName(day, "Second")}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user