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
@@ -147,10 +147,6 @@
</div>
<style>
.mono {
font-family: var(--ox-font-mono);
}
.table-box {
flex: 1;
min-height: 0;
@@ -249,7 +245,7 @@
font-size: 13px;
}
/* Celle orario */
/* Time cells */
.slot {
display: flex;
flex-direction: column;
@@ -356,7 +352,7 @@
background: rgba(77, 141, 255, 0.12);
}
/* Note */
/* Notes */
td.note {
font-size: 13px;
color: #6e6e6e;
@@ -70,7 +70,7 @@
</span>
{/if}
<!-- Bottone diviso Esporta -->
<!-- Split export button -->
<div class="split-wrap">
<div class="split">
<button type="button" class="split-main" onclick={onexportall}>
@@ -178,7 +178,7 @@
font-size: 13.5px;
}
/* Bottone diviso */
/* Split button */
.split-wrap {
position: relative;
z-index: 21;
+22 -31
View File
@@ -1,19 +1,20 @@
// Logica pura di analisi delle timbrature di un mese, estratta dal
// componente di revisione per tenerlo leggibile. Le stringhe tradotte
// vengono prodotte passando la funzione `t` (di solito `$_` di
// svelte-i18n) e, dove serve la lingua per Intl, `locale` ($locale).
// Pure logic for analyzing a month's attendance records, extracted from
// the review component to keep it readable. Translated strings are
// produced by passing the `t` function (usually svelte-i18n's `$_`) and,
// where Intl needs the language, `locale` ($locale).
import { weekdayName } from '$lib/month';
import type { Shifts } from '$lib/shifts';
import { initials } from '$lib/text';
import { fmt, formatTyped, hm, parseT } from '$lib/time';
export { formatTyped, hm, parseT };
export { formatTyped, hm, initials, parseT };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches svelte-i18n's own loosely-typed `values`
export type Translate = (key: string, options?: { values?: Record<string, any> }) => string;
/** [Entrata1[], Uscita1[], Entrata2[], Uscita2[]] — ogni casella è un
* elenco di orari "HH:MM" per rappresentare anche le timbrature impilate. */
/** [In1[], Out1[], In2[], Out2[]] — each slot is a list of "HH:MM" times,
* to also represent stacked punches. */
export type Day = [string[], string[], string[], string[]];
export interface Employee {
@@ -78,7 +79,7 @@ export function dayWord(n: number, t: Translate): string {
return t('review.day_word', { values: { n } });
}
/** [entrata, uscita] se il giorno ha solo 2 timbrature che scavalcano la pausa pranzo. */
/** [in, out] if the day has exactly 2 punches spanning the lunch break. */
export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
const all = day.flat().filter((x) => x !== '');
if (all.length !== 2) return null;
@@ -92,12 +93,12 @@ export function lunchSpan(day: Day, shift: Shifts): [string, string] | null {
}
/**
* Ridistribuisce timbrature finite tutte nello stesso turno (es. per un
* timbratore configurato male) nelle 4 caselle corrette, scegliendo per
* ciascuna l'orario previsto (E1/U1/E2/U2) più vicino. Dato che gli orari
* sono ordinati e i turni previsti pure, basta confrontare ogni timbratura
* col punto a metà strada fra due turni consecutivi: è esattamente il
* confine fra "più vicino al turno prima" e "più vicino al turno dopo".
* Redistributes punches that all ended up in the same shift (e.g. from a
* misconfigured time clock) into the correct 4 slots, picking for each one
* the closest expected shift time (E1/U1/E2/U2). Since both the punches and
* the expected shifts are sorted, it's enough to compare each punch against
* the midpoint between two consecutive shifts: that's exactly the boundary
* between "closer to the shift before" and "closer to the shift after".
*/
export function assignToShifts(times: string[], shift: Shifts): Day {
const targets = [shift.in1, shift.out1, shift.in2, shift.out2].map((x) => parseT(x) as number);
@@ -147,7 +148,7 @@ export function analyze(
const miss: [boolean, boolean, boolean, boolean] = [false, false, false, false];
let issue = '';
// 1. Turno sbagliato
// 1. Wrong shift
if (stacked) {
const total = lists.reduce((n, c) => n + c.length, 0);
const shiftLabel = filled[0] || filled[1] ? t('review.shift_1') : t('review.shift_2');
@@ -156,9 +157,9 @@ export function analyze(
if (!filled[i]) miss[i] = true;
});
}
// 2. Orario non valido
// 2. Invalid time
if (!issue && bad.some((c) => c.some(Boolean))) issue = t('review.issue_invalid_time');
// 3. Pausa pranzo non timbrata
// 3. Unpunched lunch break
const lunch = !stacked && !issue && lunchSpan(v, shift) !== null;
if (lunch) {
issue = t('review.issue_lunch_missing');
@@ -166,7 +167,7 @@ export function analyze(
if (!filled[i]) miss[i] = true;
});
}
// 4-5. Coppie incomplete / uscita prima dell'entrata
// 4-5. Incomplete pairs / clock-out before clock-in
if (!stacked && !lunch) {
const p = lists.map((c) => (c.length ? parseT(c[0]) : null));
for (const [a, b, shiftKey] of [
@@ -186,7 +187,7 @@ export function analyze(
}
}
// Ore: coppie per posizione dentro ciascun turno
// Hours: pairs by position within each shift
let mins = 0;
for (const [a, b] of [
[0, 1],
@@ -206,7 +207,7 @@ export function analyze(
const wasStacked = o.some((c) => c.length > 1);
const wasLunch = !lunch && lunchSpan(o, shift) !== null && v[1][0] === shift.out1 && v[2][0] === shift.in2;
// 6. Info: orari non arrotondati
// 6. Info: unrounded times
const unr = v.map((c) => c.map((x) => x !== '' && !isRounded(x, shift.round_step_min)));
const hasUnr = unr.some((c) => c.some(Boolean));
const info = !issue && hasUnr;
@@ -232,7 +233,7 @@ export function dayLabel(r: Row): string {
return `${r.dow} ${r.di + 1}`;
}
/** "dip-giorno-casella-indice" -> è l'orario attuale il risultato di un arrotondamento? */
/** "employee-day-slot-index" -> is the current time the result of a rounding? */
export function isRnd(
rounded: Record<string, string>,
emp: number,
@@ -271,16 +272,6 @@ export function cellClass(r: Row, ci: number, ii: number, rnd: boolean): string
return c;
}
export function initials(n: string): string {
return n
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
export function summary(iss: number, inf: number, t: Translate): string {
return [iss ? `${dayWord(iss, t)} ${t('review.to_check')}` : '', inf ? `${dayWord(inf, t)} ${t('review.to_round')}` : '']
.filter(Boolean)
@@ -2,7 +2,7 @@ import type { Day, Employee } from './analysis';
type Clock = 'FirstIn' | 'FirstOut' | 'SecondIn' | 'SecondOut';
/** Un record come lo restituisce il comando Tauri `load_records`. */
/** A record as returned by the `load_records` Tauri command. */
export interface AttendanceRecord {
employee_id: number;
clock: Clock;
@@ -21,7 +21,12 @@ function timeOf(datetime: string): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** Raggruppa i record per dipendente e giorno nella forma Day attesa da TimesheetReview. */
/**
* Groups records by employee and day into the Day shape expected by
* TimesheetReview. Only employees with at least one record this month are
* included — `names` is just used to resolve a display name, not to decide
* who shows up (it may hold every employee ever cached, across all months).
*/
export function buildEmployees(
year: number,
month: number,
@@ -31,13 +36,12 @@ export function buildEmployees(
const daysInMonth = new Date(year, month, 0).getDate();
const byEmployee = new Map<number, Day[]>();
for (const id of Object.keys(names)) {
byEmployee.set(Number(id), Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day));
}
for (const record of records) {
const days = byEmployee.get(record.employee_id);
if (!days) continue;
let days = byEmployee.get(record.employee_id);
if (!days) {
days = Array.from({ length: daysInMonth }, () => [[], [], [], []] as Day);
byEmployee.set(record.employee_id, days);
}
const date = new Date(record.datetime);
const day = days[date.getDate() - 1];
day[CLOCK_INDEX[record.clock]].push(timeOf(record.datetime));