feat(oxibadge): add settings screen for shifts, rounding and language
Check, format and test / build (push) Successful in 10m22s

Add a settings screen (gear button on the month-confirmation screen)
to configure the expected shift times, the rounding step, the
interface language, and the time clock address — replacing the old
onboarding-only connection flow. Settings persist to the same
preferences.toml as the reader address, under a single load_settings/
save_settings command pair.

The review screen's "fix shifts" action now redistributes stacked
punches by nearest configured shift time instead of blind position,
and a stacked cell manually cleared down to one entry no longer
leaves a dangling empty input.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:29:57 +02:00
parent acd3efeac6
commit b65e85f908
26 changed files with 1397 additions and 118 deletions
+16 -10
View File
@@ -1,7 +1,7 @@
mod preferences;
use chrono::{Local, TimeZone};
use preferences::Reader;
use preferences::Settings;
use r701::{Record, R701};
use serde::Deserialize;
use std::collections::HashMap;
@@ -9,13 +9,13 @@ use tauri::AppHandle;
use tokio_stream::StreamExt;
#[tauri::command]
fn load_preferences(app: AppHandle) -> Option<Reader> {
fn load_settings(app: AppHandle) -> Settings {
preferences::load(&app)
}
#[tauri::command]
fn save_preferences(app: AppHandle, host: String, port: u16) -> Result<(), String> {
preferences::save(&app, Reader { host, port })
fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
preferences::save(&app, &settings)
}
#[tauri::command]
@@ -46,8 +46,11 @@ async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
/// chronological (oldest first) order before being returned.
#[tauri::command]
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let settings = preferences::load(&app);
if settings.reader.host.is_empty() {
return Err("no reader configured".to_string());
}
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
let invalid = || "invalid year/month".to_string();
let start = Local
@@ -85,8 +88,11 @@ async fn load_employees(
app: AppHandle,
records: Vec<Record>,
) -> Result<HashMap<u32, String>, String> {
let reader = preferences::load(&app).ok_or_else(|| "no reader configured".to_string())?;
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let settings = preferences::load(&app);
if settings.reader.host.is_empty() {
return Err("no reader configured".to_string());
}
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
let mut employees = HashMap::new();
for record in &records {
@@ -145,8 +151,8 @@ fn export_timesheet(payload: ExportPayload) {
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
load_preferences,
save_preferences,
load_settings,
save_settings,
test_connection,
load_records,
load_employees,
+57 -16
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -7,25 +8,65 @@ pub struct Reader {
pub port: u16,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Preferences {
#[serde(default)]
reader: Vec<Reader>,
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shifts {
pub in1: String,
pub out1: String,
pub in2: String,
pub out2: String,
pub round_step_min: u32,
}
pub fn load(app: &AppHandle) -> Option<Reader> {
let path = app.path().app_data_dir().ok()?.join("preferences.toml");
let contents = std::fs::read_to_string(path).ok()?;
let preferences: Preferences = toml::from_str(&contents).ok()?;
preferences.reader.into_iter().next()
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub reader: Reader,
pub shifts: Shifts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
}
pub fn save(app: &AppHandle, reader: Reader) -> Result<(), String> {
let dir = app.path().app_data_dir().map_err(|err| err.to_string())?;
std::fs::create_dir_all(&dir).map_err(|err| err.to_string())?;
let preferences = Preferences {
reader: vec![reader],
impl Default for Settings {
fn default() -> Self {
Self {
reader: Reader {
host: String::new(),
port: 5005,
},
shifts: Shifts {
in1: "07:30".to_string(),
out1: "12:00".to_string(),
in2: "13:30".to_string(),
out2: "17:00".to_string(),
round_step_min: 10,
},
lang: None,
}
}
}
fn path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|err| err.to_string())?
.join("preferences.toml"))
}
pub fn load(app: &AppHandle) -> Settings {
let Ok(path) = path(app) else {
return Settings::default();
};
let contents = toml::to_string_pretty(&preferences).map_err(|err| err.to_string())?;
std::fs::write(dir.join("preferences.toml"), contents).map_err(|err| err.to_string())
std::fs::read_to_string(path)
.ok()
.and_then(|contents| toml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(app: &AppHandle, settings: &Settings) -> Result<(), String> {
let dest = path(app)?;
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).map_err(|err| err.to_string())?;
}
let contents = toml::to_string_pretty(settings).map_err(|err| err.to_string())?;
std::fs::write(dest, contents).map_err(|err| err.to_string())
}