feat(oxibadge): cache employee names locally and let readers be a list
Check, format and test / build (push) Successful in 10m27s

Employee names are now cached locally (employees.toml, keyed by reader
index then employee id) instead of being re-fetched from the time
clock on every month: load_employees only asks the device for ids not
already in the cache. When a month has employees the cache doesn't
know yet, a new screen (NewEmployees.svelte) lets the user name them
before continuing to the review screen; the chosen names are local to
Oxibadge only, never written back to the device. The same cache can be
managed afterwards from a drawer reachable from the time clock card in
Settings ("Gestisci dipendenti"), reusing the rename-in-row pattern
from the original mockup.

Settings.readers is a list again (was flattened to a single `reader`
object) so multiple time clocks can be supported later; only the first
one is used today.

Also:
- buildEmployees() now derives the employee list from the month's
  records instead of from every cached name ever seen, so the review
  screen no longer shows employees unrelated to the selected month.
- Renaming an employee from the review screen's pencil button now
  actually persists to the cache (the callback existed but was never
  wired up).
- Translated to English a number of Rust/TS/Svelte comments that had
  been written in Italian; only the i18n locale files should contain
  Italian.
- Minor dedup: extracted PillButton.svelte (was duplicated inline in
  ReaderCard), promoted the repeated .mono CSS rule to app.css, moved
  initials() out of the review feature into $lib/text.ts.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:04:37 +02:00
parent b65e85f908
commit 25127bee96
26 changed files with 1109 additions and 143 deletions
+39
View File
@@ -0,0 +1,39 @@
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// User-chosen employee names, by employee_id (as a string, to be a valid
/// TOML table).
pub type EmployeeNames = HashMap<String, String>;
/// `EmployeeNames` by reader index (as a string, same reason), since an
/// employee_id is only unique within one reader. Local to Oxibadge only:
/// the time clock is never written to.
pub type Cache = HashMap<String, EmployeeNames>;
fn path(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|err| err.to_string())?
.join("employees.toml"))
}
pub fn load(app: &AppHandle) -> Cache {
let Ok(path) = path(app) else {
return Cache::new();
};
std::fs::read_to_string(path)
.ok()
.and_then(|contents| toml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(app: &AppHandle, cache: &Cache) -> 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(cache).map_err(|err| err.to_string())?;
std::fs::write(dest, contents).map_err(|err| err.to_string())
}
+27 -9
View File
@@ -1,10 +1,11 @@
mod employees;
mod preferences;
use chrono::{Local, TimeZone};
use preferences::Settings;
use r701::{Record, R701};
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use tauri::AppHandle;
use tokio_stream::StreamExt;
@@ -18,6 +19,16 @@ fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
preferences::save(&app, &settings)
}
#[tauri::command]
fn load_employee_cache(app: AppHandle) -> employees::Cache {
employees::load(&app)
}
#[tauri::command]
fn save_employee_cache(app: AppHandle, cache: employees::Cache) -> Result<(), String> {
employees::save(&app, &cache)
}
#[tauri::command]
async fn test_connection(host: String, port: u16) -> Result<(), String> {
connect_with_retries(&host, port).await.map(|_| ())
@@ -47,10 +58,10 @@ async fn connect_with_retries(host: &str, port: u16) -> Result<R701, String> {
#[tauri::command]
async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Record>, String> {
let settings = preferences::load(&app);
if settings.reader.host.is_empty() {
let Some(reader) = settings.readers.first() else {
return Err("no reader configured".to_string());
}
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
};
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let invalid = || "invalid year/month".to_string();
let start = Local
@@ -82,21 +93,26 @@ async fn load_records(app: AppHandle, year: i32, month: u32) -> Result<Vec<Recor
Ok(records)
}
/// Resolves the name of every distinct employee in `records`.
/// Resolves the name of every distinct employee in `records`, skipping
/// `known` ones (already in the frontend's local cache) to avoid asking the
/// device for names it has already told us. Returns only the newly fetched
/// names, keyed by employee id.
#[tauri::command]
async fn load_employees(
app: AppHandle,
records: Vec<Record>,
known: Vec<u32>,
) -> Result<HashMap<u32, String>, String> {
let settings = preferences::load(&app);
if settings.reader.host.is_empty() {
let Some(reader) = settings.readers.first() else {
return Err("no reader configured".to_string());
}
let r701 = connect_with_retries(&settings.reader.host, settings.reader.port).await?;
};
let r701 = connect_with_retries(&reader.host, reader.port).await?;
let known: HashSet<u32> = known.into_iter().collect();
let mut employees = HashMap::new();
for record in &records {
if employees.contains_key(&record.employee_id) {
if known.contains(&record.employee_id) || employees.contains_key(&record.employee_id) {
continue;
}
let name = r701
@@ -153,6 +169,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
load_settings,
save_settings,
load_employee_cache,
save_employee_cache,
test_connection,
load_records,
load_employees,
+5 -5
View File
@@ -19,7 +19,10 @@ pub struct Shifts {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub reader: Reader,
/// The configured time clock (only the first one is used today, but
/// this stays a list to support multiple readers in the future).
#[serde(default)]
pub readers: Vec<Reader>,
pub shifts: Shifts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
@@ -28,10 +31,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
reader: Reader {
host: String::new(),
port: 5005,
},
readers: Vec::new(),
shifts: Shifts {
in1: "07:30".to_string(),
out1: "12:00".to_string(),