From 25127bee96bee9d0cc40cd1d83eff78e380e5cd8 Mon Sep 17 00:00:00 2001 From: Nicola Belluti Date: Thu, 8 Oct 2026 20:04:37 +0200 Subject: [PATCH] feat(oxibadge): cache employee names locally and let readers be a list 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 --- oxibadge/src-tauri/src/employees.rs | 39 ++ oxibadge/src-tauri/src/lib.rs | 36 +- oxibadge/src-tauri/src/preferences.rs | 10 +- oxibadge/src/app.css | 4 + .../src/lib/components/ConfirmMonth.svelte | 1 - oxibadge/src/lib/components/IconButton.svelte | 18 +- .../src/lib/components/NewEmployees.svelte | 267 ++++++++++++ oxibadge/src/lib/components/Onboarding.svelte | 2 +- oxibadge/src/lib/components/PillButton.svelte | 58 +++ oxibadge/src/lib/components/Settings.svelte | 8 +- .../src/lib/components/TimesheetReview.svelte | 28 +- .../src/lib/components/new-employees/logic.ts | 12 + .../components/review/TimesheetTable.svelte | 8 +- .../src/lib/components/review/TopBar.svelte | 4 +- .../src/lib/components/review/analysis.ts | 53 +-- .../src/lib/components/review/fromRecords.ts | 20 +- .../settings/EmployeesDrawer.svelte | 407 ++++++++++++++++++ .../lib/components/settings/ReaderCard.svelte | 140 +++--- oxibadge/src/lib/components/settings/logic.ts | 2 +- oxibadge/src/lib/employees.ts | 10 + oxibadge/src/lib/locales/en.json | 23 +- oxibadge/src/lib/locales/it.json | 23 +- oxibadge/src/lib/settings.ts | 4 +- oxibadge/src/lib/text.ts | 9 + oxibadge/src/lib/time.ts | 2 +- oxibadge/src/routes/+page.svelte | 64 ++- 26 files changed, 1109 insertions(+), 143 deletions(-) create mode 100644 oxibadge/src-tauri/src/employees.rs create mode 100644 oxibadge/src/lib/components/NewEmployees.svelte create mode 100644 oxibadge/src/lib/components/PillButton.svelte create mode 100644 oxibadge/src/lib/components/new-employees/logic.ts create mode 100644 oxibadge/src/lib/components/settings/EmployeesDrawer.svelte create mode 100644 oxibadge/src/lib/employees.ts create mode 100644 oxibadge/src/lib/text.ts diff --git a/oxibadge/src-tauri/src/employees.rs b/oxibadge/src-tauri/src/employees.rs new file mode 100644 index 0000000..49ff599 --- /dev/null +++ b/oxibadge/src-tauri/src/employees.rs @@ -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; + +/// `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; + +fn path(app: &AppHandle) -> Result { + 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()) +} diff --git a/oxibadge/src-tauri/src/lib.rs b/oxibadge/src-tauri/src/lib.rs index 083d545..be3df7b 100644 --- a/oxibadge/src-tauri/src/lib.rs +++ b/oxibadge/src-tauri/src/lib.rs @@ -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 { #[tauri::command] async fn load_records(app: AppHandle, year: i32, month: u32) -> Result, 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, + known: Vec, ) -> Result, 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 = 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, diff --git a/oxibadge/src-tauri/src/preferences.rs b/oxibadge/src-tauri/src/preferences.rs index f69f0f9..8f1054e 100644 --- a/oxibadge/src-tauri/src/preferences.rs +++ b/oxibadge/src-tauri/src/preferences.rs @@ -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, pub shifts: Shifts, #[serde(default, skip_serializing_if = "Option::is_none")] pub lang: Option, @@ -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(), diff --git a/oxibadge/src/app.css b/oxibadge/src/app.css index cfb85b6..542c5d6 100644 --- a/oxibadge/src/app.css +++ b/oxibadge/src/app.css @@ -60,6 +60,10 @@ h1 { color: var(--ox-text-strong); } +.mono { + font-family: var(--ox-font-mono); +} + .red { color: #ff9ea3; } diff --git a/oxibadge/src/lib/components/ConfirmMonth.svelte b/oxibadge/src/lib/components/ConfirmMonth.svelte index 9c023fa..ee307db 100644 --- a/oxibadge/src/lib/components/ConfirmMonth.svelte +++ b/oxibadge/src/lib/components/ConfirmMonth.svelte @@ -311,7 +311,6 @@ .opt.mono { flex: none; - font-family: var(--ox-font-mono); font-size: 16px; } diff --git a/oxibadge/src/lib/components/IconButton.svelte b/oxibadge/src/lib/components/IconButton.svelte index ec4c733..33b9f6b 100644 --- a/oxibadge/src/lib/components/IconButton.svelte +++ b/oxibadge/src/lib/components/IconButton.svelte @@ -4,6 +4,7 @@ let { size = 'sm', accent = false, + danger = false, title, ariaLabel, onclick, @@ -11,6 +12,7 @@ }: { size?: 'sm' | 'lg'; accent?: boolean; + danger?: boolean; title?: string; ariaLabel: string; onclick: () => void; @@ -18,7 +20,16 @@ } = $props(); - @@ -65,4 +76,9 @@ .icon-btn.accent:hover { filter: brightness(1.08); } + + .icon-btn.danger:hover { + background: rgba(229, 72, 77, 0.14); + color: #ff8a8f; + } diff --git a/oxibadge/src/lib/components/NewEmployees.svelte b/oxibadge/src/lib/components/NewEmployees.svelte new file mode 100644 index 0000000..ab8946a --- /dev/null +++ b/oxibadge/src/lib/components/NewEmployees.svelte @@ -0,0 +1,267 @@ + + +
+
+
+

{$_('new_employees.title')}

+

+ {$_('new_employees.subtitle', { values: { n: count } })} + {$_('new_employees.hint')} +

+
+ +
+ {#each employees as emp, i (emp.id)} + {@const empty = isEmpty(names[i])} + {@const edited = isEdited(names[i], emp.name)} +
+ #{emp.id} + onKeydown(e, i)} + /> +
+ {/each} +
+ + +
+
+ + diff --git a/oxibadge/src/lib/components/Onboarding.svelte b/oxibadge/src/lib/components/Onboarding.svelte index 4d25375..d1ad20b 100644 --- a/oxibadge/src/lib/components/Onboarding.svelte +++ b/oxibadge/src/lib/components/Onboarding.svelte @@ -30,7 +30,7 @@ try { await invoke('test_connection', { host: trimmedHost, port }); const settings = await invoke('load_settings'); - await invoke('save_settings', { settings: { ...settings, reader: { host: trimmedHost, port } } }); + await invoke('save_settings', { settings: { ...settings, readers: [{ host: trimmedHost, port }] } }); onconnected({ host: trimmedHost, port }); } catch (err) { setError(err); diff --git a/oxibadge/src/lib/components/PillButton.svelte b/oxibadge/src/lib/components/PillButton.svelte new file mode 100644 index 0000000..993d8e1 --- /dev/null +++ b/oxibadge/src/lib/components/PillButton.svelte @@ -0,0 +1,58 @@ + + + + + diff --git a/oxibadge/src/lib/components/Settings.svelte b/oxibadge/src/lib/components/Settings.svelte index 6c3e049..4ce43db 100644 --- a/oxibadge/src/lib/components/Settings.svelte +++ b/oxibadge/src/lib/components/Settings.svelte @@ -18,7 +18,7 @@ let loaded = $state(false); let host = $state(''); let port = $state(5005); - // Placeholder: sovrascritti da load_settings prima che le card siano renderizzate ({#if loaded}). + // Placeholder: overwritten by load_settings before the cards are rendered ({#if loaded}). let shifts = $state({ in1: '', out1: '', in2: '', out2: '', round_step_min: 0 }); let roundStepStr = $state(''); let lang = $state(null); @@ -29,8 +29,8 @@ onMount(async () => { const settings = await invoke('load_settings'); - host = settings.reader.host; - port = settings.reader.port; + host = settings.readers[0]?.host ?? ''; + port = settings.readers[0]?.port ?? 5005; shifts = { ...settings.shifts }; roundStepStr = String(settings.shifts.round_step_min); lang = settings.lang; @@ -44,7 +44,7 @@ async function persist() { saveState = 'saving'; const settings: SettingsData = { - reader: { host, port }, + readers: [{ host, port }], shifts: { ...shifts, round_step_min: Number(roundStepStr) }, lang }; diff --git a/oxibadge/src/lib/components/TimesheetReview.svelte b/oxibadge/src/lib/components/TimesheetReview.svelte index 0619d75..158f2a6 100644 --- a/oxibadge/src/lib/components/TimesheetReview.svelte +++ b/oxibadge/src/lib/components/TimesheetReview.svelte @@ -43,17 +43,17 @@ onback?: () => void; } = $props(); - // Copie di lavoro: il componente non modifica le props. Per caricare un - // altro mese va rimontato (nella nostra app accade già da sé, dato che - // la schermata è dietro un {#if step === 'review'}). `untrack` rende - // esplicito che leggiamo `employees` una sola volta, di proposito. + // Working copies: the component never mutates the props. Loading another + // month remounts it (already the case in this app, since this screen + // sits behind a {#if step === 'review'}). `untrack` makes it explicit + // that we read `employees` only once, on purpose. const orig: Day[][] = untrack(() => employees.map((e) => e.days.map(normDay))); let values = $state(untrack(() => employees.map((e) => e.days.map(normDay)))); let names = $state(untrack(() => employees.map((e) => e.name))); - /** "dip-giorno-casella-indice" -> orario prodotto dall'arrotondamento */ + /** "employee-day-slot-index" -> time produced by rounding */ let rounded = $state>({}); - // ---- Stato UI ---- + // ---- UI state ---- let emp = $state(0); let query = $state(''); let onlyIssues = $state(false); @@ -78,23 +78,23 @@ ); const visibleRows = $derived(cur.rows.filter((r) => !onlyIssues || r.issue || r.info)); - // ---- Azioni sui dati ---- + // ---- Data actions ---- function onCellInput(di: number, ci: number, ii: number, e: Event & { currentTarget: HTMLInputElement }) { const input = e.currentTarget; const cell = values[emp][di][ci]; const prev = cell[ii] ?? ''; const next = formatTyped(input.value, prev, (e as unknown as InputEvent).inputType ?? ''); if (next === '' && cell.length > 1) { - // Una delle timbrature impilate è stata svuotata a mano: la casella si - // restringe invece di lasciare un campo vuoto appeso. + // One of the stacked punches was cleared by hand: the cell shrinks + // instead of leaving a dangling empty field. cell.splice(ii, 1); } else { cell[ii] = next; - if (input.value !== next) input.value = next; // allinea il campo anche se lo stato non cambia + if (input.value !== next) input.value = next; // keep the field in sync even if the state doesn't change } } - /** Ridistribuisce le timbrature del giorno in E1, U1, E2, U2 in base ai turni previsti. */ + /** Redistributes the day's punches into E1, U1, E2, U2 based on the expected shifts. */ function fixDays(dis: number[]) { for (const di of dis) { const times = values[emp][di].flat().filter((x) => x !== ''); @@ -121,13 +121,13 @@ } } - // ---- Nome ---- + // ---- Name ---- function renameCurrent(name: string) { names[emp] = name; onrename?.(employees[emp].id, name); } - // ---- Esportazione ---- + // ---- Export ---- function requestExport(scope: 'all' | 'one') { menuOpen = false; const pending = scope === 'all' ? issuesAll + infosAll : cur.issues + cur.infos; @@ -165,7 +165,7 @@ $effect(() => () => clearTimeout(toastTimer)); - // Testi dell'avviso di esportazione + // Export warning dialog texts const scopeIssues = $derived(confirmScope === 'all' ? issuesAll : cur.issues); const scopeInfos = $derived(confirmScope === 'all' ? infosAll : cur.infos); const confirmSummary = $derived( diff --git a/oxibadge/src/lib/components/new-employees/logic.ts b/oxibadge/src/lib/components/new-employees/logic.ts new file mode 100644 index 0000000..a08b3a8 --- /dev/null +++ b/oxibadge/src/lib/components/new-employees/logic.ts @@ -0,0 +1,12 @@ +export interface NewEmployee { + id: number; + name: string; +} + +export function isEmpty(name: string): boolean { + return !name.trim(); +} + +export function isEdited(name: string, original: string): boolean { + return !isEmpty(name) && name.trim() !== original; +} diff --git a/oxibadge/src/lib/components/review/TimesheetTable.svelte b/oxibadge/src/lib/components/review/TimesheetTable.svelte index 2dfc691..edbe155 100644 --- a/oxibadge/src/lib/components/review/TimesheetTable.svelte +++ b/oxibadge/src/lib/components/review/TimesheetTable.svelte @@ -147,10 +147,6 @@ diff --git a/oxibadge/src/lib/components/settings/ReaderCard.svelte b/oxibadge/src/lib/components/settings/ReaderCard.svelte index a3ea9f2..dedd187 100644 --- a/oxibadge/src/lib/components/settings/ReaderCard.svelte +++ b/oxibadge/src/lib/components/settings/ReaderCard.svelte @@ -1,11 +1,15 @@ {#if step === 'review'} @@ -69,7 +120,10 @@ shift={shifts} onback={() => (step = 'confirm-month')} onexport={(payload) => invoke('export_timesheet', { payload })} + onrename={onEmployeeRenamed} /> +{:else if step === 'new-employees'} + {:else if step === 'settings'}