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
@@ -1,11 +1,15 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { error, setError, clearError } from '$lib/error';
import type { Settings } from '$lib/settings';
import { PRIMARY_READER_INDEX, type EmployeeCache, type EmployeeNames } from '$lib/employees';
import Field from '../Field.svelte';
import ErrorBanner from '../ErrorBanner.svelte';
import ActionButton from '../review/ActionButton.svelte';
import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte';
import { IPV4 } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
@@ -18,6 +22,48 @@
let status = $state<'' | 'testing' | 'ok'>('');
let fullEmployeeCache = $state<EmployeeCache>({});
let employeeCache = $state<EmployeeNames>({});
let employeesOpen = $state(false);
let employeeQuery = $state('');
onMount(async () => {
fullEmployeeCache = await invoke<EmployeeCache>('load_employee_cache');
employeeCache = fullEmployeeCache[PRIMARY_READER_INDEX] ?? {};
});
const employeesList = $derived(
Object.entries(employeeCache)
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
);
const employeeCount = $derived(employeesList.length);
const visibleEmployees = $derived(
employeesList.filter(
(e) =>
!employeeQuery.trim() ||
e.name.toLowerCase().includes(employeeQuery.trim().toLowerCase()) ||
e.id.includes(employeeQuery.trim())
)
);
async function persistEmployees(next: EmployeeNames) {
employeeCache = next;
const nextFullCache = { ...fullEmployeeCache, [PRIMARY_READER_INDEX]: next };
fullEmployeeCache = nextFullCache;
await invoke('save_employee_cache', { cache: nextFullCache });
}
function renameEmployee(id: string, name: string) {
persistEmployees({ ...employeeCache, [id]: name });
}
function removeEmployee(id: string) {
const next = { ...employeeCache };
delete next[id];
persistEmployees(next);
}
function startEdit() {
draftHost = host;
draftPort = port;
@@ -43,7 +89,7 @@
clearError();
try {
const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, reader: { host: v, port: nextPort } } });
await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
onsave(v, nextPort);
} catch (err) {
setError(err);
@@ -106,18 +152,33 @@
</span>
</div>
<div class="dev-actions">
<button type="button" class="pill" onclick={startEdit}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
<span>{$_('settings.edit_address')}</span>
</button>
<button type="button" class="pill" onclick={test} disabled={status === 'testing'}>
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
<span>{$_('settings.test_connection')}</span>
</button>
<PillButton onclick={startEdit}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4z" /><path d="M13.5 6.5l4 4" /></svg
>
{/snippet}
{$_('settings.edit_address')}
</PillButton>
<PillButton onclick={test} disabled={status === 'testing'}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0M12 19.5h.01" /></svg
>
{/snippet}
{$_('settings.test_connection')}
</PillButton>
<PillButton onclick={() => (employeesOpen = true)}>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"
><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c.8-3.3 3.3-5 6.5-5s5.7 1.7 6.5 5" /><path
d="M16 4.5a3.5 3.5 0 0 1 0 7M18 15.2c1.8.7 3 2.3 3.5 4.8"
/></svg
>
{/snippet}
{$_('settings.manage_employees')}
<span class="count mono">{employeeCount}</span>
</PillButton>
</div>
{:else}
<form
@@ -162,11 +223,18 @@
<ErrorBanner title={$_('errors.title')} message={$error} />
{/if}
<style>
.mono {
font-family: var(--ox-font-mono);
}
{#if employeesOpen}
<EmployeesDrawer
employees={visibleEmployees}
total={employeeCount}
bind:query={employeeQuery}
onclose={() => (employeesOpen = false)}
onrename={renameEmployee}
ondelete={removeEmployee}
/>
{/if}
<style>
.spin {
animation: spin 0.9s linear infinite;
}
@@ -256,40 +324,10 @@
border-top: 1px solid #222222;
}
.pill {
all: unset;
box-sizing: border-box;
height: 36px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 7px;
border-radius: 9px;
border: 1px solid #333333;
color: #c9c9c9;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.pill:hover:not(:disabled) {
background: #1e1e1e;
color: #ffffff;
border-color: #4a4a4a;
}
.pill:disabled {
opacity: 0.5;
cursor: progress;
}
.pill:focus-visible {
outline: 2px solid var(--ox-accent);
outline-offset: 2px;
.count {
margin-left: 6px;
font-size: 12.5px;
color: #6e6e6e;
}
.dev-edit {