From 92a9c6d70433fe23400db6bb58c68cc8e00da357 Mon Sep 17 00:00:00 2001 From: stefiosif Date: Sun, 11 Jan 2026 22:28:10 +0200 Subject: [PATCH] Implement DTOs, add managed state for schedule, export to txt --- src-tauri/src/config.rs | 24 ++++++- src-tauri/src/export.rs | 45 +++++++++---- src-tauri/src/lib.rs | 39 +++++++---- src-tauri/src/resident.rs | 40 ++++++++++- src-tauri/src/schedule.rs | 27 +++++++- src/routes/+page.svelte | 7 +- .../components/configurations/basic.svelte | 2 - .../configurations/residents.svelte | 8 +-- .../components/schedule/generate.svelte | 64 ++++++++++++------ src/routes/components/schedule/preview.svelte | 2 +- src/routes/state.svelte.ts | 66 +++++++++++++++++-- 11 files changed, 254 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 0aab83d..f228b0d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,15 +1,25 @@ use std::collections::HashMap; use chrono::Month; +use serde::{Deserialize, Serialize}; use crate::{ - resident::Resident, + resident::{Resident, ResidentDTO}, schedule::{MonthlySchedule, ShiftType}, slot::{Day, ShiftPosition, Slot}, }; const YEAR: i32 = 2026; +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct UserConfigDTO { + month: usize, + year: i32, + holidays: Vec, + residents: Vec, + toxic_pairs: Vec<(String, String)>, +} + #[derive(Debug)] pub struct UserConfig { month: Month, @@ -36,6 +46,18 @@ impl UserConfig { } } + pub fn from_dto(dto: UserConfigDTO) -> Self { + Self { + month: Month::try_from(dto.month as u8).unwrap(), + year: dto.year, + holidays: dto.holidays, + residents: dto.residents.into_iter().map(Resident::from_dto).collect(), + toxic_pairs: dto.toxic_pairs, + workload_limits: HashMap::new(), + holiday_limits: HashMap::new(), + } + } + pub fn with_holidays(mut self, holidays: Vec) -> Self { self.holidays = holidays; self diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index d857638..2fd2c80 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -1,9 +1,17 @@ // here lies the logic for the export of the final schedule into docx/pdf formats -use crate::schedule::MonthlySchedule; +use std::{fs::File, io::Write}; + +use log::info; + +use crate::{ + config::UserConfig, + schedule::MonthlySchedule, +}; #[derive(Debug)] pub enum FileType { + Txt, Json, Csv, Doc, @@ -11,16 +19,17 @@ pub enum FileType { } pub trait Export { - fn export(&self, file_type: FileType); + fn export(&self, file_type: FileType, config: &UserConfig); } impl Export for MonthlySchedule { - fn export(&self, file_type: FileType) { + fn export(&self, file_type: FileType, config: &UserConfig) { match file_type { - FileType::Csv => self.export_as_csv(), - FileType::Json => self.export_as_json(), - FileType::Doc => self.export_as_doc(), - FileType::Pdf => self.export_as_pdf(), + FileType::Txt => self.export_as_txt(config), + FileType::Csv => self.export_as_csv(config), + FileType::Json => self.export_as_json(config), + FileType::Doc => self.export_as_doc(config), + FileType::Pdf => self.export_as_pdf(config), }; // TODO: make this env var from a config file? Option to change this in-app @@ -29,27 +38,35 @@ impl Export for MonthlySchedule { "exported type {:?}. Saved at folder path {}", file_type, env_path ); - todo!() } } impl MonthlySchedule { - // return error result as string or nothing, maybe use anyhow - // or just return a string.. for now + pub fn export_as_txt(&self, config: &UserConfig) -> String { + let file = File::create("schedule.txt").unwrap(); + let mut writer = std::io::BufWriter::new(file); + writer + .write_all(self.pretty_print(config).as_bytes()) + .expect("Failed to write to buffer"); - pub fn export_as_csv(&self) -> String { + writer.flush().expect("Failed to flush buffer"); + info!("im here"); + "ok".to_string() + } + + pub fn export_as_csv(&self, config: &UserConfig) -> String { todo!() } - pub fn export_as_json(&self) -> String { + pub fn export_as_json(&self, config: &UserConfig) -> String { todo!() } - pub fn export_as_doc(&self) -> String { + pub fn export_as_doc(&self, config: &UserConfig) -> String { todo!() } - pub fn export_as_pdf(&self) -> String { + pub fn export_as_pdf(&self, config: &UserConfig) -> String { todo!() } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 195ccbd..111e646 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ +use std::sync::Mutex; + use log::info; use crate::{ - config::UserConfig, + config::{UserConfig, UserConfigDTO}, export::{Export, FileType}, generator::backtracking, schedule::MonthlySchedule, @@ -16,35 +18,46 @@ mod resident; mod schedule; mod slot; +struct AppState { + schedule: Mutex, +} + /// argument to this must be the rota state including all /// information for the residents, the forbidden pairs the holidays /// and the period of the schedule #[tauri::command] -fn generate() -> String { - let config = UserConfig::default(); +fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> MonthlySchedule { + info!("{:?}", config); + let mut config = UserConfig::from_dto(config); + config.calculate_workload_limits(); + config.calculate_holiday_limits(); let mut schedule = MonthlySchedule::new(); schedule.prefill(&config); - - backtracking(&mut schedule, Slot::default(), &config); info!("{}", schedule.pretty_print(&config)); - schedule.export_as_json() + let solved = backtracking(&mut schedule, Slot::default(), &config); + let mut internal_schedule = state.schedule.lock().unwrap(); + *internal_schedule = schedule.clone(); + assert!(solved); + info!("{}", schedule.pretty_print(&config)); + + schedule } /// export into docx #[tauri::command] -fn export() -> String { - let schedule = MonthlySchedule::new(); - let filetype = FileType::Doc; - - schedule.export(filetype); - - todo!() +fn export(config: UserConfigDTO, state: tauri::State<'_, AppState>) { + let config = UserConfig::from_dto(config); + let schedule = state.schedule.lock().unwrap(); + schedule.export(FileType::Txt, &config); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(AppState { + schedule: Mutex::new(MonthlySchedule::new()), + }) .plugin( tauri_plugin_log::Builder::new() .level(tauri_plugin_log::log::LevelFilter::Info) diff --git a/src-tauri/src/resident.rs b/src-tauri/src/resident.rs index 89de7ab..893f059 100644 --- a/src-tauri/src/resident.rs +++ b/src-tauri/src/resident.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::{ schedule::ShiftType, - slot::{Day, Slot}, + slot::{Day, ShiftPosition, Slot}, }; #[derive(Serialize, Deserialize, Debug, Clone)] @@ -17,6 +17,18 @@ pub struct Resident { pub reduced_load: bool, } +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ResidentDTO { + id: String, + name: String, + negative_shifts: Vec, + manual_shifts: Vec, + max_shifts: Option, + allowed_types: Vec, + reduced_load: bool, +} + impl Resident { pub fn new(id: &str, name: &str) -> Self { Self { @@ -43,4 +55,30 @@ impl Resident { self.reduced_load = true; self } + + pub fn from_dto(dto: ResidentDTO) -> Self { + Self { + id: dto.id, + name: dto.name, + negative_shifts: dto + .negative_shifts + .into_iter() + .map(|d| Day(d as u8)) + .collect(), + manual_shifts: dto + .manual_shifts + .into_iter() + .map(|s| { + Slot { + day: s.day, + // FIXME: frontend always brings resident manual shifts as first + position: ShiftPosition::First, + } + }) + .collect(), + max_shifts: dto.max_shifts, + allowed_types: dto.allowed_types, + reduced_load: dto.reduced_load, + } + } } diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs index 095623a..0ccfe11 100644 --- a/src-tauri/src/schedule.rs +++ b/src-tauri/src/schedule.rs @@ -4,12 +4,33 @@ use std::collections::HashMap; use crate::{ config::UserConfig, resident::Resident, - slot::{Day, Slot}, + slot::{Day, ShiftPosition, Slot}, }; +use serde::Serializer; + +impl Serialize for MonthlySchedule { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (slot, name) in &self.0 { + let pos_str = match slot.position { + ShiftPosition::First => "First", + ShiftPosition::Second => "Second", + }; + let key = format!("{}-{}", slot.day.0, pos_str); + map.serialize_entry(&key, name)?; + } + map.end() + } +} + // each slot has one resident // a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even) -#[derive(Debug)] +#[derive(Deserialize, Debug, Clone)] pub struct MonthlySchedule(HashMap); impl MonthlySchedule { @@ -179,7 +200,7 @@ impl MonthlySchedule { } } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum ShiftType { Closed, OpenFirst, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a6e4606..06d4187 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,6 @@ - +

{steps[rota.currentStep - 1].title}

@@ -78,15 +99,15 @@ onclick={() => "handleCellClick(day, 1)"} class="w-full overflow-hidden rounded border border-pink-200 bg-pink-50 px-1.5 py-1 text-left text-[10px] font-bold text-pink-600 transition-colors hover:bg-pink-100" > - {getResidentName(day, 1)} + {getResidentName(day, "First")} {/if} - {#if slotCount == 2} + {#if slotCount > 1} {/if}
@@ -99,13 +120,14 @@ diff --git a/src/routes/components/schedule/preview.svelte b/src/routes/components/schedule/preview.svelte index e759300..0bdfe4d 100644 --- a/src/routes/components/schedule/preview.svelte +++ b/src/routes/components/schedule/preview.svelte @@ -21,7 +21,7 @@ return day % 2 === 0 ? 1 : 2; } - +

{steps[rota.currentStep - 1].title}

diff --git a/src/routes/state.svelte.ts b/src/routes/state.svelte.ts index e7d0d3f..8d8d437 100644 --- a/src/routes/state.svelte.ts +++ b/src/routes/state.svelte.ts @@ -1,6 +1,5 @@ // state.svelte.ts import { CalendarDate, getDayOfWeek } from "@internationalized/date"; -import { trace } from "@tauri-apps/plugin-log"; export interface Resident { id: string; @@ -9,7 +8,7 @@ export interface Resident { manualShifts: CalendarDate[]; maxShifts: number | undefined; allowedTypes: string[]; - hasReducedLoad: boolean; + reducedLoad: boolean; } export interface ForbiddenPair { @@ -30,6 +29,8 @@ export class RotaState { daysArray = $derived(Array.from({ length: this.projectMonthDays }, (_, i) => i + 1)); emptySlots = $derived(Array.from({ length: getDayOfWeek(this.projectMonth, "en-GB") })); + solution = $state({} as MonthlySchedule); + addResident() { this.residents.push({ id: crypto.randomUUID(), @@ -37,13 +38,17 @@ export class RotaState { negativeShifts: [], manualShifts: [], maxShifts: undefined, - allowedTypes: ["OpenAsFirst", "OpenAsSecond", "Closed"], - hasReducedLoad: false + allowedTypes: ["Closed", "OpenFirst", "OpenSecond"], + reducedLoad: false }); } removeResident(id: string) { - this.residents = this.residents.filter((p) => p.id !== id); + this.residents = this.residents.filter((r) => r.id !== id); + } + + findResident(id: string) { + return this.residents.find((r) => r.id === id); } addForbiddenPair() { @@ -57,10 +62,61 @@ export class RotaState { removeForbiddenPair(index: number) { this.forbiddenPairs.splice(index, 1); } + + toDTO(): UserConfigDTO { + return { + month: this.selectedMonth, + year: this.selectedYear, + holidays: this.holidays.map((d) => d.day), + residents: this.residents.map((r) => ({ + id: r.id, + name: r.name, + negativeShifts: r.negativeShifts.map((d) => d.day), + manualShifts: r.manualShifts.map((s) => ({ + day: s.day, + position: "First" + })), + maxShifts: r.maxShifts ?? null, + allowedTypes: r.allowedTypes as ShiftType[], + reducedLoad: r.reducedLoad + })), + + toxic_pairs: this.forbiddenPairs.map((pair) => [pair.id1, pair.id2]) + }; + } } export const rota = new RotaState(); +export type MonthlyScheduleDTO = { + schedule: Record>; +}; + +export type UserConfigDTO = { + month: number; + year: number; + holidays: Array; + residents: Array; + toxic_pairs: Array<[string, string]>; +}; + +export type ResidentDTO = { + id: string; + name: string; + negativeShifts: Array; + manualShifts: Array<{ day: number; position: ShiftPosition }>; + maxShifts: number | null; + allowedTypes: Array; + reducedLoad: boolean; +}; + +export type ShiftType = "Closed" | "OpenFirst" | "OpenSecond"; + +export type ShiftPosition = "First" | "Second"; + +export type SlotKey = `${number}-${"First" | "Second"}`; +export type MonthlySchedule = { [key: SlotKey]: string }; + export const steps = [ { id: 1,