From 9d8eacfb2b474f325806fa8495c03ad99b89c6d8 Mon Sep 17 00:00:00 2001 From: stefiosif Date: Thu, 2 Jul 2026 19:15:07 +0300 Subject: [PATCH] Add option to manually swap shifts after the schedule has been generated --- src-tauri/src/errors.rs | 23 ++ src-tauri/src/lib.rs | 118 +++++++- src-tauri/src/schedule.rs | 272 +++++++++++++++++- src-tauri/src/slot.rs | 21 ++ src-tauri/tests/integration.rs | 30 ++ .../components/schedule/generate.svelte | 115 ++++++-- src/routes/state.svelte.ts | 2 +- 7 files changed, 550 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index b870eab..cc93f64 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -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(&self, serializer: S) -> Result where diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f35d167..8d19048 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { 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 { + 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 = 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 { + 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"); } diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs index 864bc0c..6d4a53c 100644 --- a/src-tauri/src/schedule.rs +++ b/src-tauri/src/schedule.rs @@ -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()); + } } diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs index c347aee..7532105 100644 --- a/src-tauri/src/slot.rs +++ b/src-tauri/src/slot.rs @@ -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 { + match s { + "First" => Ok(ShiftPosition::First), + "Second" => Ok(ShiftPosition::Second), + _ => Err(()), + } + } +} + #[cfg(test)] mod tests { use rstest::rstest; diff --git a/src-tauri/tests/integration.rs b/src-tauri/tests/integration.rs index ef175ff..53c1b80 100644 --- a/src-tauri/tests/integration.rs +++ b/src-tauri/tests/integration.rs @@ -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()); diff --git a/src/routes/components/schedule/generate.svelte b/src/routes/components/schedule/generate.svelte index d13a892..8f16b6a 100644 --- a/src/routes/components/schedule/generate.svelte +++ b/src/routes/components/schedule/generate.svelte @@ -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([]); + 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("perform_swap", { + slotADay: selectedSlot.day, + slotAPosition: selectedSlot.position, + slotBDay: day, + slotBPosition: position + }); + rota.metrics = await invoke("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("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 _}
{/each} {#each rota.daysArray as day (day)} {@const slotCount = getRequiredSlots(day)} -
+
{day}
{#if slotCount > 0} - {/if} {#if slotCount > 1} - {/if} diff --git a/src/routes/state.svelte.ts b/src/routes/state.svelte.ts index 1ddc058..dd0e221 100644 --- a/src/routes/state.svelte.ts +++ b/src/routes/state.svelte.ts @@ -41,7 +41,7 @@ export class RotaState { projectMonth = $derived(new CalendarDate(this.selectedYear, this.selectedMonth, 1)); projectMonthDays = $derived(this.projectMonth.calendar.getDaysInMonth(this.projectMonth)); - + daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1)); emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") }));