Implement DTOs, add managed state for schedule, export to txt

This commit is contained in:
2026-01-11 22:28:10 +02:00
parent 53f8695572
commit 92a9c6d704
11 changed files with 254 additions and 70 deletions

View File

@@ -1,15 +1,25 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::Month; use chrono::Month;
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
resident::Resident, resident::{Resident, ResidentDTO},
schedule::{MonthlySchedule, ShiftType}, schedule::{MonthlySchedule, ShiftType},
slot::{Day, ShiftPosition, Slot}, slot::{Day, ShiftPosition, Slot},
}; };
const YEAR: i32 = 2026; const YEAR: i32 = 2026;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserConfigDTO {
month: usize,
year: i32,
holidays: Vec<usize>,
residents: Vec<ResidentDTO>,
toxic_pairs: Vec<(String, String)>,
}
#[derive(Debug)] #[derive(Debug)]
pub struct UserConfig { pub struct UserConfig {
month: Month, 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<usize>) -> Self { pub fn with_holidays(mut self, holidays: Vec<usize>) -> Self {
self.holidays = holidays; self.holidays = holidays;
self self

View File

@@ -1,9 +1,17 @@
// here lies the logic for the export of the final schedule into docx/pdf formats // 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)] #[derive(Debug)]
pub enum FileType { pub enum FileType {
Txt,
Json, Json,
Csv, Csv,
Doc, Doc,
@@ -11,16 +19,17 @@ pub enum FileType {
} }
pub trait Export { pub trait Export {
fn export(&self, file_type: FileType); fn export(&self, file_type: FileType, config: &UserConfig);
} }
impl Export for MonthlySchedule { impl Export for MonthlySchedule {
fn export(&self, file_type: FileType) { fn export(&self, file_type: FileType, config: &UserConfig) {
match file_type { match file_type {
FileType::Csv => self.export_as_csv(), FileType::Txt => self.export_as_txt(config),
FileType::Json => self.export_as_json(), FileType::Csv => self.export_as_csv(config),
FileType::Doc => self.export_as_doc(), FileType::Json => self.export_as_json(config),
FileType::Pdf => self.export_as_pdf(), 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 // 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 {}", "exported type {:?}. Saved at folder path {}",
file_type, env_path file_type, env_path
); );
todo!()
} }
} }
impl MonthlySchedule { impl MonthlySchedule {
// return error result as string or nothing, maybe use anyhow pub fn export_as_txt(&self, config: &UserConfig) -> String {
// or just return a string.. for now 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!() todo!()
} }
pub fn export_as_json(&self) -> String { pub fn export_as_json(&self, config: &UserConfig) -> String {
todo!() todo!()
} }
pub fn export_as_doc(&self) -> String { pub fn export_as_doc(&self, config: &UserConfig) -> String {
todo!() todo!()
} }
pub fn export_as_pdf(&self) -> String { pub fn export_as_pdf(&self, config: &UserConfig) -> String {
todo!() todo!()
} }
} }

View File

@@ -1,7 +1,9 @@
use std::sync::Mutex;
use log::info; use log::info;
use crate::{ use crate::{
config::UserConfig, config::{UserConfig, UserConfigDTO},
export::{Export, FileType}, export::{Export, FileType},
generator::backtracking, generator::backtracking,
schedule::MonthlySchedule, schedule::MonthlySchedule,
@@ -16,35 +18,46 @@ mod resident;
mod schedule; mod schedule;
mod slot; mod slot;
struct AppState {
schedule: Mutex<MonthlySchedule>,
}
/// argument to this must be the rota state including all /// argument to this must be the rota state including all
/// information for the residents, the forbidden pairs the holidays /// information for the residents, the forbidden pairs the holidays
/// and the period of the schedule /// and the period of the schedule
#[tauri::command] #[tauri::command]
fn generate() -> String { fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> MonthlySchedule {
let config = UserConfig::default(); info!("{:?}", config);
let mut config = UserConfig::from_dto(config);
config.calculate_workload_limits();
config.calculate_holiday_limits();
let mut schedule = MonthlySchedule::new(); let mut schedule = MonthlySchedule::new();
schedule.prefill(&config); schedule.prefill(&config);
backtracking(&mut schedule, Slot::default(), &config);
info!("{}", schedule.pretty_print(&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 /// export into docx
#[tauri::command] #[tauri::command]
fn export() -> String { fn export(config: UserConfigDTO, state: tauri::State<'_, AppState>) {
let schedule = MonthlySchedule::new(); let config = UserConfig::from_dto(config);
let filetype = FileType::Doc; let schedule = state.schedule.lock().unwrap();
schedule.export(FileType::Txt, &config);
schedule.export(filetype);
todo!()
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(AppState {
schedule: Mutex::new(MonthlySchedule::new()),
})
.plugin( .plugin(
tauri_plugin_log::Builder::new() tauri_plugin_log::Builder::new()
.level(tauri_plugin_log::log::LevelFilter::Info) .level(tauri_plugin_log::log::LevelFilter::Info)

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
schedule::ShiftType, schedule::ShiftType,
slot::{Day, Slot}, slot::{Day, ShiftPosition, Slot},
}; };
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -17,6 +17,18 @@ pub struct Resident {
pub reduced_load: bool, pub reduced_load: bool,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ResidentDTO {
id: String,
name: String,
negative_shifts: Vec<usize>,
manual_shifts: Vec<Slot>,
max_shifts: Option<usize>,
allowed_types: Vec<ShiftType>,
reduced_load: bool,
}
impl Resident { impl Resident {
pub fn new(id: &str, name: &str) -> Self { pub fn new(id: &str, name: &str) -> Self {
Self { Self {
@@ -43,4 +55,30 @@ impl Resident {
self.reduced_load = true; self.reduced_load = true;
self 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,
}
}
} }

View File

@@ -4,12 +4,33 @@ use std::collections::HashMap;
use crate::{ use crate::{
config::UserConfig, config::UserConfig,
resident::Resident, resident::Resident,
slot::{Day, Slot}, slot::{Day, ShiftPosition, Slot},
}; };
use serde::Serializer;
impl Serialize for MonthlySchedule {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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 // each slot has one resident
// a day can span between 1 or 2 slots depending on if it is open(odd) or closed(even) // 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<Slot, String>); pub struct MonthlySchedule(HashMap<Slot, String>);
impl MonthlySchedule { impl MonthlySchedule {
@@ -179,7 +200,7 @@ impl MonthlySchedule {
} }
} }
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum ShiftType { pub enum ShiftType {
Closed, Closed,
OpenFirst, OpenFirst,

View File

@@ -1,9 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Calendar, Popover } from "bits-ui"; import { trace } from "@tauri-apps/plugin-log";
import { CalendarDate, getDayOfWeek } from "@internationalized/date";
import { Button } from "$lib/components/ui/button/index.js";
import { warn, debug, trace, info, error } from "@tauri-apps/plugin-log";
import Basic from "./components/configurations/basic.svelte"; import Basic from "./components/configurations/basic.svelte";
import Residents from "./components/configurations/residents.svelte"; import Residents from "./components/configurations/residents.svelte";
@@ -99,7 +96,7 @@
<div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200"> <div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200">
<div <div
class="h-full bg-emerald-600 transition-all duration-500" class="h-full bg-emerald-600 transition-all duration-500"
style="width: {(rota.currentStep / steps.length) * 100}%" style="width: {(((rota.currentStep - 1) / (steps.length - 1)) * 100).toFixed(0)}%"
></div> ></div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { CalendarDate } from "@internationalized/date";
import { trace } from "@tauri-apps/plugin-log";
import { Calendar, Popover } from "bits-ui"; import { Calendar, Popover } from "bits-ui";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { rota, steps } from "../../state.svelte.js"; import { rota, steps } from "../../state.svelte.js";

View File

@@ -321,7 +321,7 @@
<div class="flex flex-col items-center space-y-2 px-4"> <div class="flex flex-col items-center space-y-2 px-4">
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Shift Types</p> <p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Shift Types</p>
<div class="flex gap-1"> <div class="flex gap-1">
{#each ["OpenAsFirst", "OpenAsSecond", "Closed"] as type} {#each ["Closed", "OpenFirst", "OpenSecond"] as type}
{@const active = resident.allowedTypes.includes(type)} {@const active = resident.allowedTypes.includes(type)}
<button <button
type="button" type="button"
@@ -348,14 +348,14 @@
Reduced Workload Reduced Workload
</p> </p>
<button <button
onclick={() => (resident.hasReducedLoad = !resident.hasReducedLoad)} onclick={() => (resident.reducedLoad = !resident.reducedLoad)}
class="flex items-center gap-2 rounded-lg border px-3 py-1 transition-all hover:border-blue-200 hover:bg-white class="flex items-center gap-2 rounded-lg border px-3 py-1 transition-all hover:border-blue-200 hover:bg-white
{resident.hasReducedLoad {resident.reducedLoad
? 'border-green-200 bg-green-50 text-green-700' ? 'border-green-200 bg-green-50 text-green-700'
: 'border-zinc-200 bg-white text-zinc-500'}" : 'border-zinc-200 bg-white text-zinc-500'}"
> >
<div <div
class="size-2 rounded-full {resident.hasReducedLoad class="size-2 rounded-full {resident.reducedLoad
? 'animate-pulse bg-green-500' ? 'animate-pulse bg-green-500'
: 'bg-zinc-300'}" : 'bg-zinc-300'}"
></div> ></div>

View File

@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { rota, steps } from "../../state.svelte.js"; import { invoke } from "@tauri-apps/api/core";
import { type MonthlyScheduleDTO, rota, steps, type ShiftPosition } from "../../state.svelte.js";
function getResidentName(day: number, slot: number) { 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) => const assignedResidents = rota.residents.filter((resident) =>
resident.manualShifts.some( resident.manualShifts.some(
(shift) => (shift) =>
@@ -12,6 +18,13 @@
) )
); );
let slot;
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 : "-";
} }
@@ -21,23 +34,31 @@
return day % 2 === 0 ? 1 : 2; return day % 2 === 0 ? 1 : 2;
} }
//TODO: invoke rust? async function generate() {
// const resident = { let config = rota.toDTO();
// id: crypto.randomUUID(), console.log(config);
// name: "",
// negativeShifts: [] as CalendarDate[],
// manualShifts: [] as CalendarDate[]
// };
// try { try {
// let replyFrom = await invoke("add_resident", { resident }); let schedule = await invoke<MonthlyScheduleDTO>("generate", { config });
// console.log("Result:", replyFrom); console.log("replyFromGenerate:", schedule);
// residents = [...residents, resident]; rota.solution = schedule;
// } catch (error) { } catch (error) {
// console.error("Error:", error); console.error("Error:", error);
// } }
}
async function export_file() {
let config = rota.toDTO();
let schedule = rota.solution;
try {
await invoke("export", { config, schedule });
} catch (error) {
console.error("Error:", error);
}
}
</script> </script>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2> <h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2>
<div class="justify-end"> <div class="justify-end">
@@ -78,15 +99,15 @@
onclick={() => "handleCellClick(day, 1)"} 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" 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")}
</button> </button>
{/if} {/if}
{#if slotCount == 2} {#if slotCount > 1}
<button <button
onclick={() => "handleCellClick(day, 2)"} 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-600 transition-colors hover:bg-emerald-100" 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-600 transition-colors hover:bg-emerald-100"
> >
{getResidentName(day, 2)} {getResidentName(day, "Second")}
</button> </button>
{/if} {/if}
</div> </div>
@@ -99,13 +120,14 @@
<Button <Button
variant="outline" variant="outline"
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95" class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
onclick={() => generate()}
> >
Ανακατανομή Ανακατανομή
</Button> </Button>
<Button <Button
onclick={() => "export adasds"}
variant="outline" variant="outline"
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95" class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
onclick={() => export_file()}
> >
Εξαγωγή</Button Εξαγωγή</Button
> >

View File

@@ -21,7 +21,7 @@
return day % 2 === 0 ? 1 : 2; return day % 2 === 0 ? 1 : 2;
} }
</script> </script>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2> <h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2>
<div class="justify-end"> <div class="justify-end">

View File

@@ -1,6 +1,5 @@
// state.svelte.ts // state.svelte.ts
import { CalendarDate, getDayOfWeek } from "@internationalized/date"; import { CalendarDate, getDayOfWeek } from "@internationalized/date";
import { trace } from "@tauri-apps/plugin-log";
export interface Resident { export interface Resident {
id: string; id: string;
@@ -9,7 +8,7 @@ export interface Resident {
manualShifts: CalendarDate[]; manualShifts: CalendarDate[];
maxShifts: number | undefined; maxShifts: number | undefined;
allowedTypes: string[]; allowedTypes: string[];
hasReducedLoad: boolean; reducedLoad: boolean;
} }
export interface ForbiddenPair { export interface ForbiddenPair {
@@ -30,6 +29,8 @@ export class RotaState {
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") }));
solution = $state<MonthlySchedule>({} as MonthlySchedule);
addResident() { addResident() {
this.residents.push({ this.residents.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -37,13 +38,17 @@ export class RotaState {
negativeShifts: [], negativeShifts: [],
manualShifts: [], manualShifts: [],
maxShifts: undefined, maxShifts: undefined,
allowedTypes: ["OpenAsFirst", "OpenAsSecond", "Closed"], allowedTypes: ["Closed", "OpenFirst", "OpenSecond"],
hasReducedLoad: false reducedLoad: false
}); });
} }
removeResident(id: string) { 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() { addForbiddenPair() {
@@ -57,10 +62,61 @@ export class RotaState {
removeForbiddenPair(index: number) { removeForbiddenPair(index: number) {
this.forbiddenPairs.splice(index, 1); 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 const rota = new RotaState();
export type MonthlyScheduleDTO = {
schedule: Record<number, Record<ShiftPosition, string>>;
};
export type UserConfigDTO = {
month: number;
year: number;
holidays: Array<number>;
residents: Array<ResidentDTO>;
toxic_pairs: Array<[string, string]>;
};
export type ResidentDTO = {
id: string;
name: string;
negativeShifts: Array<number>;
manualShifts: Array<{ day: number; position: ShiftPosition }>;
maxShifts: number | null;
allowedTypes: Array<ShiftType>;
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 = [ export const steps = [
{ {
id: 1, id: 1,